@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
package/scripts/smoke-dist.mjs
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
);
|