Repository navigation
Studio: redesign Select model dropdown to match Hub design #6364
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
b612d3c
db3f38c
9b987b8
df070c3
fe918ee
9113cd7
e5ff580
4b9dd55
6cb21e0
d4639db
50ebbc4
48236d2
666662b
96d9b50
c5ba2b1
2ef7d98
0952e12
3d3a0f9
f7f4b6c
f90393f
655f7a7
0a6a02b
94ae15d
44de2f8
56b7211
cfa1681
e12fdcd
ccffeb9
392118e
c72a0a7
6c1ae6b
bb26c0d
ac760bf
c4689fc
5cc4fe5
dc00fc7
470dccb
c285df2
9def3b3
5f29ff7
dde36e9
0694bf7
a80f014
3727c97
cf0bd69
aa160c7
941a990
51d9ee8
c4cc0be
2d4d27d
def681c
4be8691
fa51cc6
c92dcfe
fc12993
a10723f
0844a4c
851daaf
3b1d5b4
bacd3e3
9a14b58
ff63200
a8450e3
d9a8b64
c1eac51
421819a
a5e1500
2a41bc2
625c00a
30fec6c
f8ab84b
7a75aa9
4ca6382
eb9d2f9
c9e171d
dbe06ff
d66752b
5c717fc
58ee3dd
934f7e4
5ae4ad4
a9152ca
9ada5b7
4c09109
ed1bd42
c350e74
79794a0
8d68063
ac09e4f
479c23d
0dab2bb
e417eea
b79ba5f
cae9637
a902119
ef90892
3f3e7fa
5b012fe
ad9656c
355f37a
77e78d7
f3ef819
9a41b6a
21caac3
c5ad30a
8935576
e57c283
d61af76
51f8b3e
1332c64
719846a
9498e10
418b134
3c8abd9
9d7bb09
3f3f432
17fe74c
da8a166
22f6959
9c4766a
fe085f9
54c5088
607c51c
edea463
4191d92
db0475f
266a3c9
164e77f
55a4010
947fe31
7bd1983
05e4859
42ebe6c
6d089a9
38c7891
dc4e968
3ddcfb1
f35aed9
b59092e
cafe7fb
2990141
9f583cc
0344869
834f096
a2a1c20
d12d7f5
95ff5aa
37065b4
e572aa1
ab8a896
a5ecc02
a3313e5
0cc0da2
bd449a8
6b28925
cc4d522
97da1aa
1e85fec
35c8ed4
7b71d64
b1abe33
73a3f62
5ad8b38
a4e57f0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,7 +8,6 @@ import { | |
| PopoverContent, | ||
| PopoverTrigger, | ||
| } from "@/components/ui/popover"; | ||
| import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; | ||
| import { usePlatformStore } from "@/config/env"; | ||
| import { isCustomProviderType } from "@/features/chat/external-providers"; | ||
| import { cn } from "@/lib/utils"; | ||
|
|
@@ -199,6 +198,72 @@ function ModelSelectorTrigger({ | |
| ); | ||
| } | ||
|
|
||
| type HubSection = "downloaded" | "recommended" | "custom"; | ||
|
|
||
| const HUB_SECTION_TABS: { value: string; label: string }[] = [ | ||
| { value: "downloaded", label: "Downloaded" }, | ||
| { value: "recommended", label: "Recommended" }, | ||
| { value: "custom", label: "Custom" }, | ||
| ]; | ||
|
|
||
| /** Segmented pill toggle reusing the Hub's .hub-tab-toggle styling (extended in | ||
| * hub.css to also match .unsloth-model-selector-menu). Keeps tab roles for | ||
| * keyboard nav. */ | ||
| function PillTabs({ | ||
| tabs, | ||
| value, | ||
| onValueChange, | ||
| ariaLabel, | ||
| className, | ||
| }: { | ||
| tabs: { value: string; label: string }[]; | ||
| value: string; | ||
| onValueChange: (value: string) => void; | ||
| ariaLabel: string; | ||
| className?: string; | ||
| }) { | ||
| const activeIndex = Math.max( | ||
| 0, | ||
| tabs.findIndex((tab) => tab.value === value), | ||
| ); | ||
| return ( | ||
| <div | ||
| role="tablist" | ||
| aria-label={ariaLabel} | ||
| className={cn( | ||
| "hub-menu-trigger hub-tab-toggle relative inline-flex h-9 items-center rounded-full", | ||
| className, | ||
| )} | ||
| > | ||
| <span | ||
| aria-hidden="true" | ||
| style={{ | ||
| width: `${100 / tabs.length}%`, | ||
| transform: `translateX(${activeIndex * 100}%)`, | ||
| }} | ||
| className="hub-tab-toggle-pill pointer-events-none absolute inset-y-0 left-0 rounded-full transition-transform duration-200 ease-out" | ||
| /> | ||
| {tabs.map((tab) => ( | ||
| <button | ||
| key={tab.value} | ||
| type="button" | ||
| role="tab" | ||
| aria-selected={value === tab.value} | ||
| onClick={() => onValueChange(tab.value)} | ||
| className={cn( | ||
| "relative z-10 inline-flex h-9 min-w-0 flex-1 items-center justify-center rounded-full px-3 text-[12.5px] transition-colors", | ||
| value === tab.value | ||
| ? "text-foreground" | ||
| : "text-muted-foreground hover:text-foreground", | ||
| )} | ||
| > | ||
| {tab.label} | ||
| </button> | ||
| ))} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| function ModelSelectorContent({ | ||
| models, | ||
| loraModels, | ||
|
|
@@ -243,6 +308,24 @@ function ModelSelectorContent({ | |
| return "hub"; | ||
| }, [externalModels, loraModels, value]); | ||
|
|
||
| const tabs = useMemo(() => { | ||
| const list: { value: string; label: string }[] = [ | ||
| { value: "hub", label: "Hub models" }, | ||
| ]; | ||
| if (!chatOnly) list.push({ value: "lora", label: "Fine-tuned" }); | ||
| if (hasExternal) list.push({ value: "external", label: "Connected" }); | ||
| return list; | ||
| }, [chatOnly, hasExternal]); | ||
|
|
||
| const [activeTab, setActiveTab] = useState<string>(() => | ||
| chatOnly ? chatOnlyTabsDefault : studioTabsDefault, | ||
| ); | ||
|
Comment on lines
+336
to
+338
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Because Useful? React with 👍 / 👎. |
||
| // Fall back to the first tab if the active one disappears. | ||
| const effectiveTab = tabs.some((tab) => tab.value === activeTab) | ||
| ? activeTab | ||
| : tabs[0].value; | ||
| const [hubSection, setHubSection] = useState<HubSection>("downloaded"); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the current checkpoint is a Hub model that is not in Downloaded (for example a recommended/search result before the cached lists catch up, or a restored selection that only exists in Useful? React with 👍 / 👎. |
||
|
|
||
| function focusActiveModelOption(root: HTMLElement): boolean { | ||
| const option = | ||
| root.querySelector<HTMLElement>( | ||
|
|
@@ -293,64 +376,60 @@ function ModelSelectorContent({ | |
| data-tour={dataTour} | ||
| onKeyDown={handlePickerEntryKeyDown} | ||
| className={cn( | ||
| "unsloth-model-selector-menu menu-soft-surface ring-0 w-[min(440px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] min-w-0 gap-0 px-3 pt-3 pb-2", | ||
| "unsloth-model-selector-menu menu-soft-surface ring-0 w-[min(520px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] min-w-0 gap-0 px-3 pt-3 pb-2", | ||
| className, | ||
| )} | ||
| > | ||
| {chatOnly ? ( | ||
| hasExternal ? ( | ||
| <Tabs defaultValue={chatOnlyTabsDefault} className="w-full"> | ||
| <TabsList className="mb-2 w-full"> | ||
| <TabsTrigger value="hub">Hub models</TabsTrigger> | ||
| <TabsTrigger value="external">Connected</TabsTrigger> | ||
| </TabsList> | ||
| <TabsContent value="hub" className="m-0"> | ||
| <HubModelPicker models={models} value={value} onSelect={onSelect} onFoldersChange={onFoldersChange} /> | ||
| </TabsContent> | ||
| <TabsContent value="external" className="m-0"> | ||
| <ExternalModelPicker | ||
| externalModels={externalModels} | ||
| value={value} | ||
| onSelect={onSelect} | ||
| /> | ||
| </TabsContent> | ||
| </Tabs> | ||
| ) : ( | ||
| <HubModelPicker models={models} value={value} onSelect={onSelect} onFoldersChange={onFoldersChange} /> | ||
| ) | ||
| ) : ( | ||
| <Tabs defaultValue={studioTabsDefault} className="w-full"> | ||
| <TabsList className="mb-2 w-full"> | ||
| <TabsTrigger value="hub">Hub models</TabsTrigger> | ||
| <TabsTrigger value="lora">Fine-tuned</TabsTrigger> | ||
| {hasExternal ? <TabsTrigger value="external">Connected</TabsTrigger> : null} | ||
| </TabsList> | ||
|
|
||
| <TabsContent value="hub" className="m-0"> | ||
| <HubModelPicker models={models} value={value} onSelect={onSelect} onFoldersChange={onFoldersChange} /> | ||
| </TabsContent> | ||
|
|
||
| <TabsContent value="lora" className="m-0"> | ||
| <LoraModelPicker | ||
| loraModels={loraModels} | ||
| value={value} | ||
| onSelect={onSelect} | ||
| onModelsChange={onModelsChange} | ||
| deleteDisabled={deleteDisabled} | ||
| {tabs.length > 1 || effectiveTab === "hub" ? ( | ||
| <div className="mb-2 flex items-center gap-2"> | ||
| {tabs.length > 1 ? ( | ||
| <PillTabs | ||
| ariaLabel="Model source" | ||
| tabs={tabs} | ||
| value={effectiveTab} | ||
| onValueChange={setActiveTab} | ||
| className={effectiveTab === "hub" ? "shrink-0" : "w-full"} | ||
| /> | ||
| </TabsContent> | ||
|
|
||
| {hasExternal ? ( | ||
| <TabsContent value="external" className="m-0"> | ||
| <ExternalModelPicker | ||
| externalModels={externalModels} | ||
| value={value} | ||
| onSelect={onSelect} | ||
| /> | ||
| </TabsContent> | ||
| ) : null} | ||
| </Tabs> | ||
| )} | ||
| {effectiveTab === "hub" ? ( | ||
| <PillTabs | ||
| ariaLabel="Hub section" | ||
| tabs={HUB_SECTION_TABS} | ||
| value={hubSection} | ||
| onValueChange={(next) => setHubSection(next as HubSection)} | ||
| className="min-w-0 flex-1" | ||
| /> | ||
| ) : null} | ||
| </div> | ||
| ) : null} | ||
|
|
||
| {effectiveTab === "hub" ? ( | ||
| <HubModelPicker | ||
| models={models} | ||
| value={value} | ||
| onSelect={onSelect} | ||
| onFoldersChange={onFoldersChange} | ||
| section={hubSection} | ||
| /> | ||
| ) : null} | ||
|
|
||
| {effectiveTab === "lora" ? ( | ||
| <LoraModelPicker | ||
| loraModels={loraModels} | ||
| value={value} | ||
| onSelect={onSelect} | ||
| onModelsChange={onModelsChange} | ||
| deleteDisabled={deleteDisabled} | ||
| /> | ||
| ) : null} | ||
|
|
||
| {effectiveTab === "external" ? ( | ||
| <ExternalModelPicker | ||
| externalModels={externalModels} | ||
| value={value} | ||
| onSelect={onSelect} | ||
| /> | ||
| ) : null} | ||
|
|
||
| {onPickLocalModel ? ( | ||
| <div className="mt-1.5 border-t border-border/70 pt-1.5"> | ||
|
|
@@ -581,7 +660,7 @@ function ExternalModelPicker({ | |
| className="h-9 pl-8" | ||
| /> | ||
| </div> | ||
| <div className="max-h-64 overflow-y-auto"> | ||
| <div className="-mr-1.5 max-h-64 overflow-y-auto pr-1.5"> | ||
| <div className="space-y-2 p-1"> | ||
| {grouped.length === 0 ? ( | ||
| <div className="px-2.5 py-2 text-xs leading-relaxed text-muted-foreground"> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To comply with WAI-ARIA tablist keyboard interaction patterns, only the active tab should be focusable via
tabIndex={0}(with inactive tabs havingtabIndex={-1}), and users should be able to navigate between tabs using theArrowLeftandArrowRightkeys. Adding anonKeyDownhandler and setting the appropriatetabIndexwill significantly improve the keyboard accessibility of the segmented pill toggle.