refactor: remove macOS vibrancy support and unify glass surfaces
Drops the macOS vibrancy toggle, IPC, and related settings copy Updates dialogs, popovers, tooltips, and dropdowns to use shared glass styles Adds sticky header fade behavior to model picker and sidebar lists
This commit is contained in:
@@ -288,6 +288,9 @@ const SortableProviderSection: React.FC<{
|
||||
};
|
||||
|
||||
const STICKY_HEADER_OFFSET = 32;
|
||||
const STICKY_FADE_MAX_SIZE = 48;
|
||||
const STICKY_FADE_MIN_SIZE = 32;
|
||||
const STICKY_FADE_CLEAR_MAX_SIZE = 24;
|
||||
|
||||
const scrollIntoView = (container: HTMLElement | null, node: HTMLElement | null) => {
|
||||
if (!node) return;
|
||||
@@ -417,6 +420,9 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
||||
const selectionStore = selectionStoreRef.current;
|
||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||
const scrollRef = React.useRef<HTMLElement | null>(null);
|
||||
const sectionHeaderSentinelRefs = React.useRef<Map<string, HTMLDivElement | null>>(new Map());
|
||||
const stickyFadeSizeRef = React.useRef(0);
|
||||
const [stuckSectionHeaders, setStuckSectionHeaders] = React.useState<Set<string>>(new Set());
|
||||
const keyboardOwnsSelectionRef = React.useRef(false);
|
||||
const lastMousePositionRef = React.useRef<{ x: number; y: number } | null>(null);
|
||||
const collapsedRecord = useModelPickerSectionsStore((state) => state.collapsedSections);
|
||||
@@ -495,6 +501,70 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
||||
})
|
||||
.filter((provider) => provider.models.length > 0), [allowedProviderSet, isHidden, isModelAllowed, matchesQuery, orderedProviders]);
|
||||
|
||||
const visibleSectionKeys = React.useMemo(() => [
|
||||
...(filteredFavorites.length > 0 ? ['favorites'] : []),
|
||||
...(filteredRecents.length > 0 ? ['recent'] : []),
|
||||
...filteredProviders.map((provider) => `provider:${provider.id}`),
|
||||
], [filteredFavorites.length, filteredProviders, filteredRecents.length]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!stickyHeaders || !scrollRef.current) {
|
||||
setStuckSectionHeaders((previous) => previous.size === 0 ? previous : new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
const root = scrollRef.current;
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
setStuckSectionHeaders((previous) => {
|
||||
const next = new Set(previous);
|
||||
let changed = false;
|
||||
for (const entry of entries) {
|
||||
const sectionKey = (entry.target as HTMLElement).dataset.modelSectionKey;
|
||||
if (!sectionKey) continue;
|
||||
const rootTop = entry.rootBounds?.top ?? root.getBoundingClientRect().top;
|
||||
const isAboveScroller = !entry.isIntersecting && entry.boundingClientRect.top < rootTop;
|
||||
if (next.has(sectionKey) === isAboveScroller) continue;
|
||||
changed = true;
|
||||
if (isAboveScroller) next.add(sectionKey);
|
||||
else next.delete(sectionKey);
|
||||
}
|
||||
return changed ? next : previous;
|
||||
});
|
||||
}, { root, threshold: 0 });
|
||||
|
||||
sectionHeaderSentinelRefs.current.forEach((element) => {
|
||||
if (element) observer.observe(element);
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, [stickyHeaders, visibleSectionKeys]);
|
||||
|
||||
const syncStickyFade = React.useCallback((scroller: HTMLElement) => {
|
||||
const hasTopScroll = scroller.scrollTop > 1;
|
||||
const fadeSize = hasTopScroll
|
||||
? Math.min(STICKY_FADE_MIN_SIZE + scroller.scrollTop, STICKY_FADE_MAX_SIZE)
|
||||
: 0;
|
||||
stickyFadeSizeRef.current = fadeSize;
|
||||
scroller.style.setProperty('--scroll-shadow-top-size', `${fadeSize}px`);
|
||||
scroller.style.setProperty(
|
||||
'--scroll-shadow-top-clear-size',
|
||||
`${Math.min(Math.max(fadeSize - 8, 0), STICKY_FADE_CLEAR_MAX_SIZE)}px`,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const blockStickyFadeInteraction = React.useCallback((
|
||||
event: React.MouseEvent<HTMLDivElement> | React.PointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
if ((event.target as Element).closest('[data-overlay-scrollbar-thumb], [data-model-picker-sticky-header]')) return;
|
||||
const eventY = event.clientY - event.currentTarget.getBoundingClientRect().top;
|
||||
if (eventY >= stickyFadeSizeRef.current) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (stickyHeaders && scrollRef.current) syncStickyFade(scrollRef.current);
|
||||
}, [stickyHeaders, syncStickyFade, visibleSectionKeys]);
|
||||
|
||||
const flatModelList = React.useMemo(() => {
|
||||
const items: ModelPickerEntry[] = [];
|
||||
if (!collapsedSections.has('favorites')) filteredFavorites.forEach((entry) => items.push(entry));
|
||||
@@ -571,7 +641,7 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
||||
|
||||
const headerClassName = cn(
|
||||
'typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 px-2 py-1.5',
|
||||
stickyHeaders && 'sticky top-0 z-10 [background:linear-gradient(var(--surface-elevated),var(--surface-elevated)),linear-gradient(var(--surface-background),var(--surface-background))]',
|
||||
stickyHeaders && 'sticky top-0 z-20',
|
||||
sectionHeaderClassName,
|
||||
);
|
||||
|
||||
@@ -678,6 +748,15 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
||||
const isSectionCollapsed = (key: string) => collapsedSections.has(key);
|
||||
const toggleSectionCollapsed = (key: string) => toggleSection(key);
|
||||
|
||||
const renderSectionSentinel = (key: string) => stickyHeaders ? (
|
||||
<div
|
||||
ref={(element) => { sectionHeaderSentinelRefs.current.set(key, element); }}
|
||||
data-model-section-key={key}
|
||||
className="pointer-events-none absolute top-0 h-px w-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const renderSectionHeader = (key: string, icon: React.ReactNode, label: React.ReactNode, headerDragProps?: SortableFavoriteHandleProps) => {
|
||||
const collapsed = isSectionCollapsed(key);
|
||||
const toggleKeyDown = (event: React.KeyboardEvent) => {
|
||||
@@ -703,6 +782,7 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
||||
tabIndex={0}
|
||||
aria-expanded={!collapsed}
|
||||
title={reorderProviderTitle}
|
||||
data-model-picker-sticky-header={stickyHeaders ? 'true' : undefined}
|
||||
className={cn(headerClassName, 'w-full text-left cursor-grab select-none active:cursor-grabbing')}
|
||||
onClick={() => toggleSectionCollapsed(key)}
|
||||
onKeyDown={toggleKeyDown}
|
||||
@@ -720,6 +800,7 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-model-picker-sticky-header={stickyHeaders ? 'true' : undefined}
|
||||
className={cn(headerClassName, 'w-full text-left cursor-pointer')}
|
||||
onClick={() => toggleSectionCollapsed(key)}
|
||||
aria-expanded={!collapsed}
|
||||
@@ -737,15 +818,43 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
||||
provider: (typeof filteredProviders)[number],
|
||||
providerIndex: number,
|
||||
headerDragProps?: SortableFavoriteHandleProps,
|
||||
) => (
|
||||
<>
|
||||
{providerIndex > 0 ? <div className="h-px bg-border/40 my-1" /> : null}
|
||||
{renderSectionHeader(`provider:${provider.id}`, <ProviderLogo providerId={provider.id} className="h-4 w-4 flex-shrink-0" />, provider.name || provider.id, headerDragProps)}
|
||||
{!isSectionCollapsed(`provider:${provider.id}`)
|
||||
? provider.models.map((model) => renderRow({ model, providerID: provider.id, modelID: model.id as string }, 'provider', false, currentFlatIndex++))
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
) => {
|
||||
const sectionKey = `provider:${provider.id}`;
|
||||
return (
|
||||
<>
|
||||
{providerIndex > 0 ? <div className="h-px bg-border/40 my-1" /> : null}
|
||||
<div className="relative">
|
||||
{renderSectionSentinel(sectionKey)}
|
||||
{renderSectionHeader(sectionKey, <ProviderLogo providerId={provider.id} className="h-4 w-4 flex-shrink-0" />, provider.name || provider.id, headerDragProps)}
|
||||
{!isSectionCollapsed(sectionKey)
|
||||
? provider.models.map((model) => renderRow({ model, providerID: provider.id, modelID: model.id as string }, 'provider', false, currentFlatIndex++))
|
||||
: null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
let stuckSectionKey: string | null = null;
|
||||
for (const sectionKey of visibleSectionKeys) {
|
||||
if (stuckSectionHeaders.has(sectionKey)) stuckSectionKey = sectionKey;
|
||||
}
|
||||
// The sidebar can seed its overlay with the first section because its first
|
||||
// header starts flush with the scroller. `Not selected` may precede the
|
||||
// first model section here, so wait for that section's sentinel rather than
|
||||
// showing its identity while the leading action is still visible.
|
||||
const leadingSectionKey = stuckSectionKey ?? (!includeNotSelected ? visibleSectionKeys[0] ?? null : null);
|
||||
const renderSectionIdentity = (sectionKey: string): React.ReactNode => {
|
||||
if (sectionKey === 'favorites') {
|
||||
return <><Icon name="star-fill" className="h-4 w-4 flex-shrink-0 text-primary" /><span className="min-w-0 truncate">{labels.favorites}</span></>;
|
||||
}
|
||||
if (sectionKey === 'recent') {
|
||||
return <><Icon name="time" className="h-4 w-4 flex-shrink-0" /><span className="min-w-0 truncate">{labels.recent}</span></>;
|
||||
}
|
||||
const providerId = sectionKey.startsWith('provider:') ? sectionKey.slice('provider:'.length) : '';
|
||||
const provider = providerById.get(providerId);
|
||||
if (!provider) return null;
|
||||
return <><ProviderLogo providerId={providerId} className="h-4 w-4 flex-shrink-0" /><span className="min-w-0 truncate">{provider.name || provider.id}</span></>;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -764,7 +873,25 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollableOverlay ref={scrollRef} outerClassName={maxHeightClassName} className="overlay-scrollbar-target--no-gutter" style={maxHeightStyle}>
|
||||
<div
|
||||
className="oc-sticky-fade-root relative flex min-h-0 flex-1"
|
||||
onPointerDownCapture={stickyHeaders ? blockStickyFadeInteraction : undefined}
|
||||
onClickCapture={stickyHeaders ? blockStickyFadeInteraction : undefined}
|
||||
onContextMenuCapture={stickyHeaders ? blockStickyFadeInteraction : undefined}
|
||||
>
|
||||
<ScrollableOverlay
|
||||
ref={scrollRef}
|
||||
useScrollShadow={stickyHeaders}
|
||||
hideTopScrollShadow={!stickyHeaders}
|
||||
scrollShadowSize={96}
|
||||
outerClassName={maxHeightClassName}
|
||||
className="oc-sticky-fade-scroller overlay-scrollbar-target--no-gutter"
|
||||
style={{
|
||||
...(stickyHeaders ? { '--scroll-shadow-top-size': '0px' } as React.CSSProperties : {}),
|
||||
...maxHeightStyle,
|
||||
}}
|
||||
onScroll={stickyHeaders ? (event) => syncStickyFade(event.currentTarget) : undefined}
|
||||
>
|
||||
<div className="px-1">
|
||||
{includeNotSelected ? (
|
||||
<>
|
||||
@@ -786,7 +913,8 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
||||
) : null}
|
||||
|
||||
{filteredFavorites.length > 0 ? (
|
||||
<div>
|
||||
<div className="relative">
|
||||
{renderSectionSentinel('favorites')}
|
||||
{renderSectionHeader('favorites', <Icon name="star-fill" className="h-4 w-4 text-primary" />, labels.favorites)}
|
||||
{!isSectionCollapsed('favorites') && (favoriteSortingEnabled ? (
|
||||
<DndContext sensors={favoriteRowSensors} collisionDetection={closestCenter} onDragEnd={handleFavoriteDragEnd}>
|
||||
@@ -806,11 +934,14 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
||||
) : null}
|
||||
|
||||
{filteredRecents.length > 0 ? (
|
||||
<div>
|
||||
<>
|
||||
{filteredFavorites.length > 0 ? <div className="h-px bg-border/40 my-1" /> : null}
|
||||
{renderSectionHeader('recent', <Icon name="time" className="h-4 w-4" />, labels.recent)}
|
||||
{!isSectionCollapsed('recent') ? filteredRecents.map((entry) => renderRow(entry, 'recent', true, currentFlatIndex++)) : null}
|
||||
</div>
|
||||
<div className="relative">
|
||||
{renderSectionSentinel('recent')}
|
||||
{renderSectionHeader('recent', <Icon name="time" className="h-4 w-4" />, labels.recent)}
|
||||
{!isSectionCollapsed('recent') ? filteredRecents.map((entry) => renderRow(entry, 'recent', true, currentFlatIndex++)) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{(filteredFavorites.length > 0 || filteredRecents.length > 0) && filteredProviders.length > 0 ? <div className="h-px bg-border/40 my-1" /> : null}
|
||||
@@ -834,6 +965,15 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
{stickyHeaders && leadingSectionKey ? (
|
||||
<div
|
||||
className="oc-sticky-fade-overlay pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center gap-2 px-3 py-1.5 typography-micro font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{renderSectionIdentity(leadingSectionKey)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="px-3 pt-1 pb-1.5 border-t border-border/40 typography-micro text-muted-foreground">
|
||||
<ModelPickerFooter store={selectionStore} flatModelList={flatModelList} footerContent={footerContent} fallback={labels.keyboardHint} />
|
||||
|
||||
Reference in New Issue
Block a user