Studio: UI polish for sidebar, menus, hub and toasts (#6288)

* Studio: UI polish for sidebar, menus, hub and toasts

- Pinned chats: add a Pinned section above Recents with a Pin/Unpin menu
  action and a hover unpin button on each pinned row
- Sidebar: align the Recents and Pinned section labels with Train, and
  switch the chat Archive icon to archive-03
- Dropdown menus: pull separators in to match item width, and even out the
  projects menu padding and radius to match the composer menu
- Account menu: match the divider width to the row hover width
- Hub toolbar: equalize the format, capability and sort filter widths so
  the search field has more room
- Model run bar: make the quant selector hover a full pill
- Tooltips: slightly larger corner radius
- Auth: reduce horizontal padding on the submit button
- Toasts: center the model load content so top and bottom padding match

* Studio: shrink pinned unpin icon, match projects menu to composer menu

* Studio: inset dropdown separators slightly from item width

* Studio: move archived chats from the sidebar into chat settings

Archive now shows a toast pointing to Settings. Archived chats are listed
and managed (open, unarchive, delete) under Settings, Chat, Data.

* Studio: make the archive toast open the archived chats list

* Studio: keep chats archived when opened from the archived list

* Chat settings: optional delete confirmation and menu cleanup

Add a "Confirm before deleting" toggle in Settings > Chat > Data. When
turned off, deleting a chat from the sidebar skips the confirm dialog
and removes it instantly. The preference is stored client-side.

Add a separator above Archive in the chat row menu so Archive and
Delete read as a group separate from Export.

Drop the duplicate divider above "Clear all chats". The section already
draws a divide-y separator between rows, so the destructive row no
longer adds its own top border.

* Hub, dialog and page polish

Hub catalog:
- Drop "inference" from the Hub subtitle.
- Make the HF token shield a circle in the header.
- Equal, narrower filter triggers that stay a fixed width; long labels
  truncate with an ellipsis instead of clipping.
- Tighter gap between the label and the dropdown chevron so one more
  character fits.
- Keep the toolbar on one row at desktop widths.
- Align the model list and row hover with the Discover tab, add right
  side breathing room.
- Make the dark-mode scrollbar lighter so it is easier to see.
- Filled size and quant chips no longer draw a border.
- Column divider uses the sidebar border color.
- Hub card extends into the bottom space.

Filters and channels:
- Add an MLX format option; detect MLX repos by library or tag.
- "Fine-tune ready" now includes 16bit safetensors, not only bnb-4bit.
- Shorten the curated channel descriptions.

Shared UI:
- New standard chevron icons (down and submenu right) shared across the
  Hub, dropdown menu, menubar, project switcher and model selector.
- Dialogs get more top and bottom padding and a larger heading.
- Guided Tour hides itself on pages with no tour.

Other pages:
- Export and Data Recipes use the same page padding as the Hub.

* Soften popup overlays and refine hub filters

Dialogs, alert dialogs and sheets now use a light blur with a slight
darken instead of a heavy black scrim, so popups stay legible without
dimming the whole screen. Edit System Prompt and Edit Chat Template
drop their custom overlays and share the same look.

Also limit the Fine-tune ready channel to checkpoints Unsloth can
actually fine-tune (drops fp8, nvfp4, w4a16 and similar), revert chip
borders so filled chips stay visible, nudge the Run icon to center it,
and shrink the submenu chevron a touch.

* Inline chat rename and review-feedback fixes

Rename a chat inline as a rounded pill on the row instead of opening a
dialog. Enter saves, Escape cancels, clicking away saves. Projects and
training runs keep the existing dialog.

Address review feedback:
- Route the no-confirm chat delete through the shared cleanup helper so
  it gets the same error toast and pin removal as the confirmed path.
- Archived chats now confirm before deleting (respecting the setting)
  and pass the active thread, so deleting the open chat resets to a new
  chat instead of leaving a stale thread URL.
- Only offer the MLX format filter on Discover, since downloaded
  inventory rows are never tagged mlx and would filter to empty.
- Tighten the non-finetunable name match so a dot-delimited token such
  as .tflite is also excluded from the Fine-tune ready channel.

* Sidebar: borderless inline rename, no name flash, align titles

Rename now edits the title as plain highlighted text with no pill or
box. While the debounced sidebar refresh catches up after a rename the
row shows the new name optimistically, so the old name no longer flashes
back in. Recent chat titles now line up flush under the Recents label.

* Profile: preferred name and avatar shape options

Add a "What should Unsloth call you?" preferred name that drives the
new-chat greeting, falling back to the first name then the login id.
Add a toggle to show the avatar as a full circle or a rounded
rectangle, applied everywhere the avatar appears.

* Settings: show version block at the top of General

Surface the Unsloth and package versions at the top of General, not
just in About. Both tabs share one StudioVersionSection so the version
fetch and markup live in a single place.

* Profile: pick a sloth sticker as your avatar

Add a curated set of sloth stickers users can choose as a profile
picture, shown below the picture shape toggle in Settings > Profile.

- Only squarish, low-whitespace stickers are listed so each one fills
  the avatar frame cleanly.
- Selecting one reuses the existing avatar persistence (localStorage),
  so it sits alongside the photo upload with no backend changes.
- Image avatars now get a neutral background so transparent stickers
  read cleanly in both shapes.

* Profile: keep transparent avatars transparent

A transparent upload is now kept as WebP and shrunk to fit instead of
falling back to JPEG, which has no alpha and was painting a background
behind the image. The image avatar is also explicitly transparent.

* Profile: fall back to PNG for transparent avatars on Safari

Safari cannot encode WebP via canvas, so a transparent upload was
throwing instead of saving. Fall back to PNG, which keeps alpha and
works in every browser, before giving up. Still never uses JPEG.

* Archived chats: reset nav when deleting the open compare too

Compare panes do not write the active thread to the store, so the pair
id only lives in the route search. Derive the open chat id from the
route (thread or compare) like the sidebar does, so deleting the open
archived compare resets to a new chat instead of a dead URL.

* Profile: trim three sloth stickers from the picker

Drop the duplicate computer sloth without text on the screen (keeping
the Local one), the nature-screen sloth, and the gift sloth.

* Chat: tighten model selector right padding

Use less right padding than left so the trailing chevron does not leave
a heavy gap on the right of the trigger.

* Address review feedback on profile, reset and rename

- Give each sloth avatar button an accessible name so screen readers
  can tell the options apart.
- Reset all local preferences now clears the delete-confirmation pref,
  so it returns to the safe confirm default.
- A no-op Enter in inline rename now closes the editor instead of
  leaving the row stuck as an input with its blur suppressed.

* Chat: rebalance model selector padding and chevron

More left padding than right and pull the chevron close to the label so
the trigger reads balanced around the text instead of heavy on the right.

* Hub: shrink the external link dialog icon

Set the icon to size-6 so it sits proportionally in the media circle
instead of filling it.

* Hub: shrink the external link dialog media circle

Override the media circle to size-12 with a size-5 icon on this dialog
only, so it is more compact without changing the shared component.
This commit is contained in:
Michael Han 2026-06-13 03:50:36 -07:00 committed by GitHub
commit 2262e02b1a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 1118 additions and 302 deletions

View file

@ -45,8 +45,7 @@ import { Switch } from "@/components/ui/switch";
import { useAnimatedThemeToggle } from "@/components/ui/animated-theme-toggler";
import { cn } from "@/lib/utils";
import {
Archive01Icon,
ArchiveRestoreIcon,
Archive03Icon,
ChefHatIcon,
CursorInfo02Icon,
DashboardCircleIcon,
@ -62,6 +61,8 @@ import {
Logout05Icon,
MoreVerticalIcon,
Search01Icon,
PinIcon,
PinOffIcon,
PlusSignIcon,
PowerIcon,
PencilEdit02Icon,
@ -95,11 +96,12 @@ import {
moveChatItemToProject,
renameChatItem,
renameChatProject,
unarchiveChatItem,
useChatRuntimeStore,
useChatProjects,
useChatSearchStore,
useChatSidebarItems,
usePinnedChatsStore,
useChatPreferencesStore,
type ProjectRecord,
type SidebarItem,
} from "@/features/chat";
@ -297,16 +299,32 @@ export function AppSidebar() {
const activeProjectId = isChatRoute
? ((search.project as string | undefined) ?? null)
: null;
const { items: allChatItems, archivedItems: archivedChatItems } =
useChatSidebarItems({
enabled: !isStudioRoute,
requireMessages: false,
});
const recentChatItems = useMemo(
() => allChatItems.filter((item) => !item.projectId),
[allChatItems],
const { items: allChatItems } = useChatSidebarItems({
enabled: !isStudioRoute,
requireMessages: false,
});
const pinnedIds = usePinnedChatsStore((s) => s.pinnedIds);
const togglePinnedChat = usePinnedChatsStore((s) => s.togglePin);
const unpinChat = usePinnedChatsStore((s) => s.unpin);
const confirmDeleteChats = useChatPreferencesStore(
(s) => s.confirmDeleteChats,
);
const [archivedOpen, setArchivedOpen] = useState(false);
const pinnedIdSet = useMemo(() => new Set(pinnedIds), [pinnedIds]);
const recentChatItems = useMemo(
() =>
allChatItems.filter(
(item) => !item.projectId && !pinnedIdSet.has(item.id),
),
[allChatItems, pinnedIdSet],
);
// Pinned chats, in pin order (most recent first).
const pinnedChatItems = useMemo(() => {
const byId = new Map(allChatItems.map((item) => [item.id, item]));
return pinnedIds
.map((id) => byId.get(id))
.filter((item): item is SidebarItem => Boolean(item));
}, [allChatItems, pinnedIds]);
const [pinnedOpen, setPinnedOpen] = useState(true);
const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId);
const setActiveThreadId = useChatRuntimeStore((s) => s.setActiveThreadId);
const activeThreadId = isChatRoute
@ -382,6 +400,19 @@ export function AppSidebar() {
});
}
// Shared chat delete: same error toast and pin cleanup whether or not the
// confirm dialog is used.
async function deleteChatWithCleanup(item: SidebarItem) {
try {
await handleDeleteThread(item);
unpinChat(item.id);
} catch (err) {
toast.error(translate("shell.toast.failedToDeleteChat"), {
description: err instanceof Error ? err.message : undefined,
});
}
}
async function handleArchiveThread(item: SidebarItem) {
try {
await archiveChatItem(item, activeThreadId, (view) => {
@ -392,6 +423,19 @@ export function AppSidebar() {
: { new: view.newThreadNonce },
});
});
const toastId = toast(
<button
type="button"
onClick={() => {
toast.dismiss(toastId);
useSettingsDialogStore.getState().openArchivedChats();
}}
className="w-full cursor-pointer text-left"
>
You can view archived chats in Settings
</button>,
{ closeButton: true },
);
} catch (err) {
toast.error("Failed to archive chat", {
description: err instanceof Error ? err.message : undefined,
@ -399,16 +443,6 @@ export function AppSidebar() {
}
}
async function handleUnarchiveThread(item: SidebarItem) {
try {
await unarchiveChatItem(item);
} catch (err) {
toast.error("Failed to unarchive chat", {
description: err instanceof Error ? err.message : undefined,
});
}
}
type RenameTarget =
| { kind: "chat"; item: SidebarItem; current: string }
| { kind: "project"; project: ProjectRecord; current: string }
@ -417,6 +451,19 @@ export function AppSidebar() {
null,
);
const [renameDraft, setRenameDraft] = useState("");
// Skips the inline rename input's blur-commit when Enter/Escape already handled it.
const skipRenameBlurRef = useRef(false);
// Optimistic title shown while the debounced sidebar refresh catches up after
// a rename, so the old name does not flash back in.
const [pendingRename, setPendingRename] = useState<{
id: string;
title: string;
} | null>(null);
useEffect(() => {
if (!pendingRename) return;
const match = allChatItems.find((i) => i.id === pendingRename.id);
if (match && match.title === pendingRename.title) setPendingRename(null);
}, [allChatItems, pendingRename]);
const [creatingProject, setCreatingProject] = useState(false);
const [projectNameDraft, setProjectNameDraft] = useState("");
const [projectCreateMoveTarget, setProjectCreateMoveTarget] =
@ -447,9 +494,11 @@ export function AppSidebar() {
if (!target || !renameDirty) return;
setRenamingTarget(null);
if (target.kind === "chat") {
setPendingRename({ id: target.item.id, title: renameTrimmed });
try {
await renameChatItem(target.item, renameTrimmed);
} catch (err) {
setPendingRename(null);
toast.error(translate("shell.toast.failedToRenameChat"), {
description: err instanceof Error ? err.message : undefined,
});
@ -476,6 +525,33 @@ export function AppSidebar() {
}
}
// Inline chat rename commits on Enter or blur, cancels on Escape.
function handleInlineRenameKeyDown(
event: React.KeyboardEvent<HTMLInputElement>,
) {
if (event.key === "Enter") {
event.preventDefault();
skipRenameBlurRef.current = true;
// Commit when changed; otherwise just close, so a no-op Enter does not
// leave the row stuck as an input with its blur suppressed.
if (renameDirty) void commitRename();
else setRenamingTarget(null);
} else if (event.key === "Escape") {
event.preventDefault();
skipRenameBlurRef.current = true;
setRenamingTarget(null);
}
}
function handleInlineRenameBlur() {
if (skipRenameBlurRef.current) {
skipRenameBlurRef.current = false;
return;
}
if (renameDirty) void commitRename();
else setRenamingTarget(null);
}
type DeleteTarget =
| { kind: "chat"; item: SidebarItem }
| { kind: "project"; project: ProjectRecord }
@ -497,13 +573,7 @@ export function AppSidebar() {
target.kind === "project" && deleteProjectFiles;
setConfirmingDelete(null);
if (target.kind === "chat") {
try {
await handleDeleteThread(target.item);
} catch (err) {
toast.error(translate("shell.toast.failedToDeleteChat"), {
description: err instanceof Error ? err.message : undefined,
});
}
await deleteChatWithCleanup(target.item);
return;
}
if (target.kind === "project") {
@ -584,6 +654,7 @@ export function AppSidebar() {
item: SidebarItem,
variant: "project" | "recent",
) {
const isPinned = pinnedIdSet.has(item.id);
const itemClass =
variant === "project"
? "group/project-chat-item relative"
@ -594,13 +665,43 @@ export function AppSidebar() {
: "sidebar-row-action group-hover/recent-item:opacity-100 group-hover/recent-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto";
const buttonClass = cn(
"sidebar-nav-btn h-[33px] cursor-pointer rounded-full pr-4 text-[14.5px] leading-[19px] tracking-nav font-medium",
// pl-3.5 starts the title at the same x as the Recents label text.
variant === "project" ? "pl-[39px]" : "pl-3.5",
// pl-3 (12px) plus the content's pl-1 (4px) lines the title up with the
// Recents label text at 16px.
variant === "project" ? "pl-[39px]" : "pl-3",
variant === "project"
? "group-hover/project-chat-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/project-chat-item:pr-8"
: "group-hover/recent-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/recent-item:pr-8",
: isPinned
? // Pinned rows show an extra unpin button on hover, so reserve more room.
"group-hover/recent-item:pr-16 group-has-[.sidebar-row-action[data-state=open]]/recent-item:pr-8"
: "group-hover/recent-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/recent-item:pr-8",
);
const isRenamingThis =
renamingTarget?.kind === "chat" && renamingTarget.item.id === item.id;
// Inline rename edits the title in place as a rounded pill, no dialog.
if (isRenamingThis) {
return (
<SidebarMenuItem key={item.id} className={itemClass}>
<input
autoFocus
value={renameDraft}
onChange={(event) => setRenameDraft(event.target.value)}
onKeyDown={handleInlineRenameKeyDown}
onBlur={handleInlineRenameBlur}
onFocus={(event) => event.currentTarget.select()}
maxLength={120}
aria-label={translate("shell.dialog.renameChat.placeholder")}
className={cn(
// No pill or box; edit in place as plain highlighted text.
"text-foreground h-[33px] w-full border-0 bg-transparent pr-4 text-[14.5px] leading-[19px] font-medium tracking-nav outline-none",
variant === "project" ? "pl-[39px]" : "pl-3",
)}
/>
</SidebarMenuItem>
);
}
return (
<SidebarMenuItem key={item.id} className={itemClass}>
<SidebarMenuButton
@ -626,7 +727,9 @@ export function AppSidebar() {
closeMobileIfOpen();
}}
>
<span className="truncate">{item.title}</span>
<span className="truncate">
{pendingRename?.id === item.id ? pendingRename.title : item.title}
</span>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -651,6 +754,10 @@ export function AppSidebar() {
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
<span>Rename</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => togglePinnedChat(item.id)}>
<HugeiconsIcon icon={isPinned ? PinOffIcon : PinIcon} strokeWidth={1.75} className="size-icon" />
<span>{isPinned ? "Unpin chat" : "Pin chat"}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<HugeiconsIcon icon={FolderExportIcon} strokeWidth={1.75} className="size-icon" />
@ -727,19 +834,46 @@ export function AppSidebar() {
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => void handleArchiveThread(item)}>
<HugeiconsIcon icon={Archive01Icon} strokeWidth={1.75} className="size-icon" />
<HugeiconsIcon icon={Archive03Icon} strokeWidth={1.75} className="size-icon" />
<span>Archive</span>
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => setConfirmingDelete({ kind: "chat", item })}
onSelect={() =>
confirmDeleteChats
? setConfirmingDelete({ kind: "chat", item })
: void deleteChatWithCleanup(item)
}
>
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.75} className="size-icon" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{isPinned ? (
<Tooltip>
<TooltipPrimitive.Trigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
unpinChat(item.id);
}}
aria-label="Unpin chat"
className={cn(actionClass, "is-unpin-action")}
>
<span className="sidebar-row-action-glyph">
<HugeiconsIcon icon={PinOffIcon} strokeWidth={1.75} className="size-4" />
</span>
</button>
</TooltipPrimitive.Trigger>
<TooltipContent side="bottom" sideOffset={6} className="tooltip-compact">
Unpin
</TooltipContent>
</Tooltip>
) : null}
</SidebarMenuItem>
);
}
@ -976,21 +1110,20 @@ export function AppSidebar() {
</SidebarGroup>
</Collapsible>
{!isStudioRoute && (
<Collapsible open={chatOpen} onOpenChange={setChatOpen} asChild>
{/* Pinned chats: own section above Recents */}
{!isStudioRoute && pinnedChatItems.length > 0 && (
<Collapsible open={pinnedOpen} onOpenChange={setPinnedOpen} asChild>
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
<SidebarGroupLabel className={cn("sidebar-sticky-label sidebar-sticky-label-following", scrolled && "is-scrolled")} asChild>
<div className="flex w-full items-center group/sb-collap">
<CollapsibleTrigger className="cursor-pointer flex flex-1 items-center gap-1 min-w-0">
{t("shell.navigation.recents")}
<ChevronDown className="size-3.5 opacity-0 transition-[transform,opacity] duration-200 group-hover/sb-collap:opacity-100 group-focus-visible/sb-collap:opacity-100 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg] [[data-state=closed]_&]:opacity-100" />
</CollapsibleTrigger>
</div>
<CollapsibleTrigger className="cursor-pointer flex w-full items-center gap-1 group/sb-collap">
Pinned
<ChevronDown className="size-3.5 opacity-0 transition-[transform,opacity] duration-200 group-hover/sb-collap:opacity-100 group-focus-visible/sb-collap:opacity-100 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg] [[data-state=closed]_&]:opacity-100" />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent className="pl-1 pr-1.5">
<SidebarMenu>
{recentChatItems.map((item) =>
{pinnedChatItems.map((item) =>
renderChatSidebarItem(item, "recent"),
)}
</SidebarMenu>
@ -1000,78 +1133,21 @@ export function AppSidebar() {
</Collapsible>
)}
{/* Archived chats — hidden on Studio + when nothing is archived */}
{!isStudioRoute && archivedChatItems.length > 0 && (
<Collapsible open={archivedOpen} onOpenChange={setArchivedOpen} asChild>
{!isStudioRoute && (
<Collapsible open={chatOpen} onOpenChange={setChatOpen} asChild>
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
<SidebarGroupLabel className={cn("sidebar-sticky-label sidebar-sticky-label-following", scrolled && "is-scrolled")} asChild>
<CollapsibleTrigger className="cursor-pointer flex w-full items-center gap-1 group/sb-collap">
Archived
{t("shell.navigation.recents")}
<ChevronDown className="size-3.5 opacity-0 transition-[transform,opacity] duration-200 group-hover/sb-collap:opacity-100 group-focus-visible/sb-collap:opacity-100 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg] [[data-state=closed]_&]:opacity-100" />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent className="px-1.5">
<SidebarGroupContent className="pl-1 pr-1.5">
<SidebarMenu>
{archivedChatItems.map((item) => (
<SidebarMenuItem key={item.id} className="group/archived-item relative">
<SidebarMenuButton
data-testid="archived-thread"
data-thread-type={item.type}
data-thread-id={item.id}
isActive={activeThreadId === item.id}
className="sidebar-nav-btn h-[33px] cursor-pointer rounded-full pl-3.5 pr-4 group-hover/archived-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/archived-item:pr-8 text-[14.5px] leading-[19px] tracking-nav font-medium text-muted-foreground"
onClick={() => {
navigate({
to: "/chat",
search:
item.type === "single"
? { thread: item.id }
: { compare: item.id },
});
closeMobileIfOpen();
}}
>
<span className="truncate">{item.title}</span>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
aria-label="Archived chat options"
className="sidebar-row-action group-hover/archived-item:opacity-100 group-hover/archived-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"
>
<span className="sidebar-row-action-glyph">
<HugeiconsIcon icon={MoreVerticalIcon} strokeWidth={1.75} className="size-icon" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="start"
sideOffset={0}
className="unsloth-plus-menu menu-flat-destructive w-52"
>
<DropdownMenuItem onSelect={() => openRenameChat(item)}>
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
<span>Rename</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void handleUnarchiveThread(item)}>
<HugeiconsIcon icon={ArchiveRestoreIcon} strokeWidth={1.75} className="size-icon" />
<span>Unarchive</span>
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => setConfirmingDelete({ kind: "chat", item })}
>
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.75} className="size-icon" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
{recentChatItems.map((item) =>
renderChatSidebarItem(item, "recent"),
)}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
@ -1252,23 +1328,24 @@ export function AppSidebar() {
: t("shell.navigation.darkMode")}
</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!getTourId(pathname)}
onSelect={() => {
const tourId = getTourId(pathname);
if (!tourId) return;
window.dispatchEvent(
new CustomEvent(TOUR_OPEN_EVENT, {
detail: { id: tourId },
}),
);
}}
>
<HugeiconsIcon icon={CursorInfo02Icon} strokeWidth={1.75} className="size-icon" />
<span>{t("shell.navigation.guidedTour")}</span>
</DropdownMenuItem>
{getTourId(pathname) && (
<DropdownMenuItem
onSelect={() => {
const tourId = getTourId(pathname);
if (!tourId) return;
window.dispatchEvent(
new CustomEvent(TOUR_OPEN_EVENT, {
detail: { id: tourId },
}),
);
}}
>
<HugeiconsIcon icon={CursorInfo02Icon} strokeWidth={1.75} className="size-icon" />
<span>{t("shell.navigation.guidedTour")}</span>
</DropdownMenuItem>
)}
</DropdownMenuGroup>
<DropdownMenuSeparator className="mx-2.5! my-2.5! h-0! border-t border-border/70 bg-transparent!" />
<DropdownMenuSeparator className="mx-1! my-2.5! h-0! border-t border-border/70 bg-transparent!" />
<DropdownMenuItem
onSelect={() => useSettingsDialogStore.getState().openDialog("about")}
>
@ -1390,7 +1467,7 @@ export function AppSidebar() {
</DialogContent>
</Dialog>
<Dialog
open={renamingTarget !== null}
open={renamingTarget !== null && renamingTarget.kind !== "chat"}
onOpenChange={(open) => {
if (!open) setRenamingTarget(null);
}}

View file

@ -13,13 +13,13 @@ import { usePlatformStore } from "@/config/env";
import { isCustomProviderType } from "@/features/chat/external-providers";
import { cn } from "@/lib/utils";
import {
ArrowDown01Icon,
CloudIcon,
DashboardSquare01Icon,
FolderSearchIcon,
RemoveCircleIcon,
Search01Icon,
} from "@hugeicons/core-free-icons";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { type KeyboardEvent, useMemo, useState } from "react";
import { Input } from "../ui/input";
@ -151,9 +151,11 @@ function ModelSelectorTrigger({
"rounded-full border border-border/60 hover:bg-[#ececec] dark:hover:bg-[#2d2e32]",
variant === "ghost" && "rounded-full hover:bg-[#ececec] dark:hover:bg-[#2d2e32]",
variant === "muted" && "rounded-full bg-muted hover:bg-muted/80",
size === "sm" && "h-8 px-2.5 text-xs",
size === "default" && "h-9 px-3 text-sm",
size === "lg" && "h-10 px-3.5 text-sm",
// More left padding than right; the chevron is pulled close to the
// label (below) so the trigger reads balanced around the text.
size === "sm" && "h-8 pl-3 pr-1.5 text-xs",
size === "default" && "h-9 pl-4 pr-2 text-sm",
size === "lg" && "h-10 pl-4.5 pr-2.5 text-sm",
className,
)}
>
@ -185,9 +187,9 @@ function ModelSelectorTrigger({
</span>
)}
</span>
<span className="flex size-4 shrink-0 items-center justify-center">
<span className="-ml-1 flex size-4 shrink-0 items-center justify-center">
<HugeiconsIcon
icon={ArrowDown01Icon}
icon={ChevronDownStandardIcon}
strokeWidth={1.75}
className="size-3.5 text-muted-foreground"
/>

View file

@ -783,13 +783,14 @@ const ThreadWelcome: FC<{
threadId?: string | null;
}> = ({ hideComposer, threadId }) => {
const displayName = useUserProfileStore((s) => s.displayName);
const nickname = useUserProfileStore((s) => s.nickname);
const [welcome, setWelcome] = useState<Welcome>(DEFAULT_WELCOME);
useEffect(() => {
// First name only, for a natural greeting; blank falls back to no name.
const name = displayName.trim().split(/\s+/)[0] ?? "";
// Prefer the nickname; otherwise first name only. Blank falls back to none.
const name = nickname.trim() || (displayName.trim().split(/\s+/)[0] ?? "");
setWelcome(buildWelcome(new Date().getHours(), name));
}, [displayName]);
}, [displayName, nickname]);
const currentEmojiSrc = `Sloth emojis/${welcome.sloth}`;

View file

@ -37,7 +37,7 @@ function AlertDialogOverlay({
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50",
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/30 duration-100 supports-backdrop-filter:backdrop-blur-[2px] fixed inset-0 z-50",
className,
)}
{...props}

View file

@ -53,7 +53,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/80 duration-100 inset-0 isolate z-50",
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/30 supports-backdrop-filter:backdrop-blur-[2px] duration-100 inset-0 isolate z-50",
position === "fixed" ? "fixed" : "absolute",
className,
)}
@ -89,7 +89,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/5 grid max-w-[calc(100%-2rem)] gap-6 rounded-4xl p-6 text-sm ring-1 duration-100 sm:max-w-md top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2",
"bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/5 grid max-w-[calc(100%-2rem)] gap-6 rounded-4xl px-7 pt-8 pb-7 text-sm ring-1 duration-100 sm:max-w-md top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2",
position === "fixed" ? "fixed" : "absolute",
className,
)}
@ -100,7 +100,7 @@ function DialogContent({
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-4 right-4"
className="absolute top-5 right-5"
size="icon-sm"
>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />
@ -158,7 +158,7 @@ function DialogTitle({
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)}
className={cn("font-heading text-lg leading-none font-semibold", className)}
{...props}
/>
);

View file

@ -5,8 +5,8 @@ import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import type * as React from "react";
import { Tick02Icon } from "@/lib/tick-icon";
import { ChevronRightStandardIcon } from "@/lib/chevron-icons";
import { cn } from "@/lib/utils";
import { ArrowRight01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
function DropdownMenu({
@ -184,7 +184,7 @@ function DropdownMenuSeparator({
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border/50 -mx-1 my-1 h-px", className)}
className={cn("bg-border/50 mx-1 my-1 h-px", className)}
{...props}
/>
);
@ -232,8 +232,8 @@ function DropdownMenuSubTrigger({
>
{children}
<HugeiconsIcon
icon={ArrowRight01Icon}
strokeWidth={2}
icon={ChevronRightStandardIcon}
strokeWidth={1.5}
className="ml-auto size-[12px]"
/>
</DropdownMenuPrimitive.SubTrigger>

View file

@ -8,7 +8,7 @@ import type * as React from "react";
import { Tick02Icon } from "@/lib/tick-icon";
import { cn } from "@/lib/utils";
import { ArrowRight01Icon } from "@hugeicons/core-free-icons";
import { ChevronRightStandardIcon } from "@/lib/chevron-icons";
import { HugeiconsIcon } from "@hugeicons/react";
function Menubar({
@ -241,8 +241,8 @@ function MenubarSubTrigger({
>
{children}
<HugeiconsIcon
icon={ArrowRight01Icon}
strokeWidth={2}
icon={ChevronRightStandardIcon}
strokeWidth={1.5}
className="ml-auto size-[12px]"
/>
</MenubarPrimitive.SubTrigger>

View file

@ -44,7 +44,7 @@ function SheetOverlay({
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/80 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs inset-0 z-50",
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/30 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-[2px] inset-0 z-50",
position === "fixed" ? "fixed" : "absolute",
className,
)}

View file

@ -443,7 +443,7 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
<Button
type="submit"
className="mx-auto flex w-fit px-6"
className="mx-auto flex w-fit px-4"
disabled={
loading ||
statusLoading ||

View file

@ -53,12 +53,12 @@ import { useIsMobile } from "@/hooks/use-mobile";
import { useLlamaUpdateCheck } from "@/hooks/use-llama-update-check";
import { cn } from "@/lib/utils";
import {
ArrowDown01Icon,
ArrowTurnBackwardIcon,
Edit03Icon,
InformationCircleIcon,
LayoutAlignRightIcon,
} from "@hugeicons/core-free-icons";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { ChevronDown, ExternalLink } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
@ -911,7 +911,7 @@ export function ChatSettingsPanel({
>
<SelectTrigger
animateRadius={false}
icon={ArrowDown01Icon}
icon={ChevronDownStandardIcon}
iconClassName="size-3.5"
className="grid h-7 w-[64px] min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-full border-transparent bg-black/[0.04] dark:bg-white/[0.05] hover:bg-black/[0.06] dark:hover:bg-white/[0.1] pl-3 pr-2 py-0 text-[13px]! font-medium text-nav-fg focus-visible:ring-0 focus-visible:border-transparent [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate [&>svg]:shrink-0"
>
@ -951,7 +951,7 @@ export function ChatSettingsPanel({
>
<SelectTrigger
animateRadius={false}
icon={ArrowDown01Icon}
icon={ChevronDownStandardIcon}
iconClassName="size-3.5"
className="grid h-7 w-[124px] min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-full border-transparent bg-black/[0.04] dark:bg-white/[0.05] hover:bg-black/[0.06] dark:hover:bg-white/[0.1] pl-3 pr-2 py-0 text-[13px]! font-medium text-nav-fg focus-visible:ring-0 focus-visible:border-transparent [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate [&>svg]:shrink-0"
data-test-id="speculative-type-select"
@ -1172,7 +1172,7 @@ export function ChatSettingsPanel({
aria-hidden="true"
>
<HugeiconsIcon
icon={ArrowDown01Icon}
icon={ChevronDownStandardIcon}
className="size-3.5"
strokeWidth={2}
/>
@ -1547,10 +1547,7 @@ export function ChatSettingsPanel({
setSystemPromptEditorOpen(nextOpen);
}}
>
<DialogContent
className="corner-squircle dialog-soft-surface sm:max-w-3xl"
overlayClassName="bg-background/35 supports-backdrop-filter:backdrop-blur-[1px]"
>
<DialogContent className="corner-squircle dialog-soft-surface sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Edit System Prompt</DialogTitle>
<DialogDescription>
@ -1827,10 +1824,7 @@ function ChatTemplateFields() {
</div>
</div>
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
<DialogContent
className="corner-squircle dialog-soft-surface sm:max-w-3xl"
overlayClassName="bg-background/35 supports-backdrop-filter:backdrop-blur-[1px]"
>
<DialogContent className="corner-squircle dialog-soft-surface sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Edit Chat Template</DialogTitle>
<DialogDescription>

View file

@ -50,8 +50,8 @@ export function ModelLoadDescription({
<div className="flex h-full shrink-0 items-center self-center">
<Spinner className="size-3.5 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
{title ? <p className="text-foreground leading-5 font-semibold">{title}</p> : null}
<div className="flex min-w-0 flex-1 flex-col justify-center">
{title ? <p className="text-foreground leading-tight font-semibold">{title}</p> : null}
{hasProgress ? (
<div className="w-full pt-1">
<div className="flex items-center justify-between gap-2 text-[10px] font-medium tracking-[0.08em] text-muted-foreground/80">

View file

@ -8,11 +8,9 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
ArrowDown01Icon,
Folder01Icon,
} from "@hugeicons/core-free-icons";
import { Folder01Icon } from "@hugeicons/core-free-icons";
import { Tick02Icon } from "@/lib/tick-icon";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import type { ReactElement } from "react";
import type { ProjectRecord } from "../types";
@ -60,7 +58,7 @@ export function ProjectSwitcher({
</span>
<span className="flex size-4 shrink-0 items-center justify-center">
<HugeiconsIcon
icon={ArrowDown01Icon}
icon={ChevronDownStandardIcon}
strokeWidth={1.75}
className="size-3.5 text-muted-foreground"
aria-hidden={true}
@ -72,7 +70,7 @@ export function ProjectSwitcher({
side="bottom"
align="start"
sideOffset={0}
className="app-user-menu menu-soft-surface ring-0 min-w-56 max-w-72 max-h-72 py-2 font-heading rounded-[14px] border-0"
className="unsloth-plus-menu ring-0 min-w-56 max-w-72 max-h-72 font-heading"
>
{showLoadingRow ? (
<DropdownMenuItem disabled={true} className="text-muted-foreground">

View file

@ -18,6 +18,8 @@ export {
} from "./chat-settings-sheet";
export { useChatRuntimeStore } from "./stores/chat-runtime-store";
export { useChatSearchStore } from "./stores/chat-search-store";
export { usePinnedChatsStore } from "./stores/pinned-chats-store";
export { useChatPreferencesStore } from "./stores/chat-preferences-store";
export {
PLUS_MENU_ORDER,
usePlusMenuPrefsStore,

View file

@ -609,7 +609,10 @@ function createStudioDbAdapter(
}
return {
remoteId: thread.id,
status: thread.archived ? "archived" : "regular",
// Always regular: archive state is owned by the app's own controls.
// Reporting archived here makes assistant-ui unarchive a chat the
// moment it is opened.
status: "regular",
title: thread.title,
};
},
@ -658,8 +661,10 @@ function createStudioDbAdapter(
},
async unarchive(remoteId: string) {
// No-op on archive state: the app owns it via the sidebar menu and the
// archived chats settings dialog. assistant-ui calls this when an
// archived chat is opened, which must not unarchive it.
await ensureStoredChatThread(remoteId);
await updateStoredChatThread(remoteId, { archived: false });
},
async delete(remoteId: string) {

View file

@ -0,0 +1,32 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { create } from "zustand";
import { persist } from "zustand/middleware";
// Client-side chat UI prefs kept in localStorage, not the chat DB.
// confirmDeleteChats: when off, deleting a chat skips the confirm dialog.
export interface ChatPreferencesState {
confirmDeleteChats: boolean;
setConfirmDeleteChats: (value: boolean) => void;
}
export const useChatPreferencesStore = create<ChatPreferencesState>()(
persist(
(set) => ({
confirmDeleteChats: true,
setConfirmDeleteChats: (confirmDeleteChats) =>
set({ confirmDeleteChats }),
}),
{
name: "unsloth_chat_preferences",
merge: (persisted, current) => {
const saved = persisted as Partial<ChatPreferencesState> | undefined;
return {
...current,
confirmDeleteChats: saved?.confirmDeleteChats ?? true,
};
},
},
),
);

View file

@ -0,0 +1,42 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { create } from "zustand";
import { persist } from "zustand/middleware";
// Client-side pin state for chats, keyed by stable chat id. Kept in
// localStorage, not the chat DB. New pins are prepended so the most
// recently pinned chat sorts first in the Pinned section.
export interface PinnedChatsState {
pinnedIds: string[];
togglePin: (id: string) => void;
unpin: (id: string) => void;
}
export const usePinnedChatsStore = create<PinnedChatsState>()(
persist(
(set) => ({
pinnedIds: [],
togglePin: (id) =>
set((state) => ({
pinnedIds: state.pinnedIds.includes(id)
? state.pinnedIds.filter((x) => x !== id)
: [id, ...state.pinnedIds],
})),
unpin: (id) =>
set((state) => ({
pinnedIds: state.pinnedIds.filter((x) => x !== id),
})),
}),
{
name: "unsloth_pinned_chats",
merge: (persisted, current) => {
const saved = persisted as Partial<PinnedChatsState> | undefined;
return {
...current,
pinnedIds: Array.isArray(saved?.pinnedIds) ? saved.pinnedIds : [],
};
},
},
),
);

View file

@ -400,7 +400,7 @@ export function DataRecipesPage(): ReactElement {
return (
<div className="min-h-[calc(100dvh-var(--studio-titlebar-height,0px))] bg-background">
<main className="mx-auto w-full max-w-7xl px-6 py-8">
<main className="mx-auto w-full max-w-7xl px-5 py-8 sm:px-9">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-[30px] font-semibold leading-[1.04] tracking-[-0.028em] text-foreground sm:text-[34px]">

View file

@ -583,7 +583,7 @@ export function ExportPage() {
// ---- Render ----
return (
<div className="min-h-[calc(100dvh-var(--studio-titlebar-height,0px))] bg-background">
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6">
<main className="mx-auto max-w-7xl px-5 py-8 sm:px-9">
<GuidedTour {...tour.tourProps} />
<div className="mb-8 flex flex-col gap-0.5">

View file

@ -39,11 +39,11 @@ export function ExternalLinkConfirmDialog() {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia>
<AlertDialogMedia className="size-12">
<HugeiconsIcon
icon={LinkSquare02Icon}
strokeWidth={1.75}
className="text-muted-foreground"
className="size-5 text-muted-foreground"
/>
</AlertDialogMedia>
<AlertDialogTitle>Open external link</AlertDialogTitle>

View file

@ -26,9 +26,9 @@ import {
normalizeGgufVariantIdentity,
} from "../lib/model-identity";
import { cn } from "@/lib/utils";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { useHfTokenStore } from "../stores/hf-token-store";
import {
ArrowDown01Icon,
Delete02Icon,
Download01Icon,
InformationCircleIcon,
@ -666,7 +666,7 @@ export function GgufDownloadCard({
e.preventDefault();
setOpen((o) => !o);
}}
className="hub-menu-trigger flex h-9 min-w-0 flex-1 cursor-pointer items-center gap-2.5 rounded-[12px] px-3 text-left transition-colors hover:bg-foreground/[0.04] data-[state=open]:bg-foreground/[0.06] dark:hover:bg-white/[0.1] dark:data-[state=open]:bg-white/[0.06]"
className="hub-menu-trigger flex h-9 min-w-0 flex-1 cursor-pointer items-center gap-2.5 rounded-full px-3 text-left transition-colors hover:bg-foreground/[0.04] data-[state=open]:bg-foreground/[0.06] dark:hover:bg-white/[0.1] dark:data-[state=open]:bg-white/[0.06]"
>
{selected ? (
<QuantBadge
@ -713,7 +713,7 @@ export function GgufDownloadCard({
</span>
)}
<HugeiconsIcon
icon={ArrowDown01Icon}
icon={ChevronDownStandardIcon}
strokeWidth={1.25}
className="ml-0.5 size-3.5 shrink-0"
/>
@ -831,7 +831,11 @@ export function GgufDownloadCard({
</>
) : selected?.downloaded ? (
<>
<HugeiconsIcon icon={PlayIcon} strokeWidth={1.75} />
<HugeiconsIcon
icon={PlayIcon}
strokeWidth={1.75}
className="translate-x-px"
/>
Run
</>
) : (

View file

@ -7,8 +7,8 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { Tick02Icon } from "@/lib/tick-icon";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { cn } from "@/lib/utils";
import { ArrowDown01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
type KeyboardEvent,
@ -156,7 +156,7 @@ export function HubOptionMenu<T extends string>({
aria-label={ariaLabel}
title={title}
className={cn(
"field-trigger hub-menu-trigger field-soft field-filter inline-flex h-9 shrink-0 cursor-pointer items-center justify-between gap-2 rounded-full px-3 text-[12.5px] transition-colors",
"field-trigger hub-menu-trigger field-soft field-filter inline-flex h-9 shrink-0 cursor-pointer items-center justify-between gap-0.5 rounded-full pl-3 pr-2 text-[12.5px] transition-colors",
"focus-visible:border-border focus-visible:ring-0 focus-visible:ring-offset-0",
className,
)}
@ -167,14 +167,18 @@ export function HubOptionMenu<T extends string>({
}
}}
>
<span className="flex min-w-0 items-center gap-2 truncate">
{triggerContent ?? selected?.triggerLabel ?? selected?.label ?? value}
<span className="flex min-w-0 items-center gap-2">
{triggerContent ?? (
<span className="min-w-0 truncate">
{selected?.triggerLabel ?? selected?.label ?? value}
</span>
)}
</span>
{showChevron && (
<HugeiconsIcon
icon={ArrowDown01Icon}
icon={ChevronDownStandardIcon}
strokeWidth={1.5}
className="size-3.5 shrink-0 text-muted-foreground"
className="size-3 shrink-0 text-muted-foreground"
/>
)}
</button>
@ -186,7 +190,7 @@ export function HubOptionMenu<T extends string>({
collisionPadding={12}
onCloseAutoFocus={(event) => event.preventDefault()}
className={cn(
"hub-menu-instant menu-soft-surface w-max min-w-[var(--radix-popover-trigger-width)] max-w-[min(var(--radix-popover-content-available-width),calc(100vw-1rem))] rounded-[22px] px-2.5 py-2 ring-0",
"hub-menu-instant menu-soft-surface w-max min-w-[var(--radix-popover-trigger-width)] max-w-[min(var(--radix-popover-content-available-width),calc(100vw-1rem))] rounded-[21px] px-[9px] py-2 ring-0",
contentClassName,
)}
>
@ -215,14 +219,14 @@ export function HubOptionMenu<T extends string>({
}}
onPointerEnter={() => activateIndex(index)}
className={cn(
"relative flex w-full min-w-0 cursor-pointer select-none items-center rounded-[12px] py-2 pr-8 pl-3 text-left text-sm leading-snug outline-none transition-colors",
"relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2.5 rounded-[12px] py-2 px-3 text-left text-sm leading-snug outline-none transition-colors",
)}
>
<span className="flex min-w-0 flex-1 items-center gap-2.5 overflow-hidden whitespace-normal break-words">
{option.label}
</span>
{selectedOption && (
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<span className="pointer-events-none flex size-4 shrink-0 items-center justify-center">
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}

View file

@ -28,9 +28,9 @@ import { ggufVariantsMatch } from "../lib/model-identity";
import { cn } from "@/lib/utils";
import { confirmExternalLink } from "../stores/external-link-confirm";
import { useHfTokenStore } from "../stores/hf-token-store";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import {
Alert02Icon,
ArrowDown01Icon,
CubeIcon,
PencilEdit02Icon,
PlayIcon,
@ -349,7 +349,7 @@ export function LocalOnDeviceCard({
</span>
)}
<HugeiconsIcon
icon={ArrowDown01Icon}
icon={ChevronDownStandardIcon}
strokeWidth={1.5}
className="size-3 shrink-0"
/>

View file

@ -232,7 +232,7 @@ function CatalogRow({
data-active={active || undefined}
onPointerEnter={arm}
onFocusCapture={arm}
className="catalog-row group/row relative block w-full select-none overflow-hidden rounded-[14px] pl-3 pr-2.5 py-2.5 text-left"
className="catalog-row group/row relative block w-full select-none overflow-hidden rounded-[14px] pl-3 pr-4 py-2.5 text-left"
>
<button
type="button"

View file

@ -273,7 +273,7 @@ export const ModelsCatalog = memo(function ModelsCatalog({
// inactive pane's scrollTop to 0 and corrupt our mirror. Visibility +
// pointer-events-none hides it while preserving native scroll state.
const scrollPaneClassName =
"absolute inset-0 min-h-0 overflow-y-auto pb-6 pl-5 pr-3 pt-0 [overflow-anchor:none] [scrollbar-gutter:stable] [scrollbar-width:thin]";
"absolute inset-0 min-h-0 overflow-y-auto pb-6 pl-4 pr-3 pt-0 [overflow-anchor:none] [scrollbar-gutter:stable] [scrollbar-width:thin]";
const discoverActive = tab === "discover";
const downloadedActive = tab === "downloaded";
const discoverInactiveHeight = Math.max(

View file

@ -77,7 +77,7 @@ export function ModelsHeader({
subtitle={
isDataset
? "Discover, download, and train on datasets locally."
: "Discover, download, and run inference models locally."
: "Discover, download, and run models locally."
}
/>

View file

@ -91,7 +91,11 @@ export const ModelsToolbar = memo(function ModelsToolbar({
const channelValue: ChannelOptionValue = activeChannelId ?? "all";
const formatOptions = useMemo<HubOption<ModelFormatFilter>[]>(
() =>
FORMAT_FILTER_OPTIONS.map((option) => ({
FORMAT_FILTER_OPTIONS.filter(
// Downloaded inventory rows are never tagged mlx, so only Discover can
// match the MLX filter; hide it elsewhere to avoid an empty list.
(option) => option.value !== "mlx" || tab === "discover",
).map((option) => ({
value: option.value,
triggerLabel: option.label,
label: (
@ -102,11 +106,14 @@ export const ModelsToolbar = memo(function ModelsToolbar({
{option.value === "checkpoint" && (
<span className="inline-block size-1.5 shrink-0 rounded-full bg-format-checkpoint" />
)}
{option.value === "mlx" && (
<span className="inline-block size-1.5 shrink-0 rounded-full bg-format-mlx" />
)}
{option.label}
</>
),
})),
[],
[tab],
);
const capabilityOptions = useMemo<HubOption<CapabilityFilter>[]>(
() =>
@ -154,10 +161,10 @@ export const ModelsToolbar = memo(function ModelsToolbar({
"focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-border",
);
return (
<div className="flex min-w-0 flex-col gap-2 lg:flex-row lg:flex-wrap lg:items-center">
<div className="flex min-w-0 flex-col gap-2 lg:flex-row lg:flex-nowrap lg:items-center">
<div
className={cn(
"hub-menu-trigger hub-tab-toggle relative inline-flex h-9 w-full shrink-0 items-center rounded-full lg:w-[240px]",
"hub-menu-trigger hub-tab-toggle relative inline-flex h-9 w-full shrink-0 items-center rounded-full lg:w-[220px]",
)}
role="radiogroup"
aria-label="View"
@ -290,7 +297,7 @@ export const ModelsToolbar = memo(function ModelsToolbar({
</div>
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2 lg:flex-[0_1_auto] lg:justify-end">
<div className="flex min-w-0 flex-wrap items-center gap-2 lg:flex-none lg:flex-nowrap lg:justify-end">
{tab === "downloaded" && !isDataset && (
<Tooltip>
<TooltipTrigger asChild>
@ -322,7 +329,7 @@ export const ModelsToolbar = memo(function ModelsToolbar({
options={formatOptions}
onValueChange={onFormatFilterChange}
ariaLabel="Format filter"
className={cn(triggerBase, "min-w-[124px]")}
className={cn(triggerBase, "w-[128px]")}
/>
)}
@ -332,7 +339,7 @@ export const ModelsToolbar = memo(function ModelsToolbar({
options={capabilityOptions}
onValueChange={onCapabilityFilterChange}
ariaLabel="Capability filter"
className={cn(triggerBase, "min-w-[136px]")}
className={cn(triggerBase, "w-[128px]")}
/>
)}
@ -342,7 +349,7 @@ export const ModelsToolbar = memo(function ModelsToolbar({
options={sortOptions}
onValueChange={onSortChange}
ariaLabel="Sort models"
className={cn(triggerBase, "min-w-[140px]")}
className={cn(triggerBase, "w-[128px]")}
/>
)}

View file

@ -87,10 +87,10 @@ export function HfTokenIndicator({ showLabel = false }: HfTokenIndicatorProps =
onClick={() => openDialog("general")}
aria-label={ariaLabel}
className={cn(
"inline-flex h-[26px] items-center justify-center px-2.5 text-[11.5px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"inline-flex size-[26px] items-center justify-center rounded-full text-[11.5px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
hasToken
? "hub-tag-soft text-muted-foreground hover:text-foreground/80"
: "rounded-full bg-destructive text-destructive-foreground hover:bg-destructive/90",
: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
)}
>
<HugeiconsIcon

View file

@ -9,9 +9,9 @@ import {
import { hasAuthToken, mustChangePassword } from "@/features/auth/session";
import { isTauri } from "@/lib/api-base";
import { cn } from "@/lib/utils";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import {
Alert02Icon,
ArrowDown01Icon,
Cancel01Icon,
CheckmarkCircle02Icon,
Download01Icon,
@ -237,7 +237,7 @@ export function DownloadManagerPanel() {
className="inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-[7px] text-muted-foreground transition-colors hover:bg-foreground/[0.06] hover:text-foreground dark:hover:bg-white/[0.1]"
>
<HugeiconsIcon
icon={ArrowDown01Icon}
icon={ChevronDownStandardIcon}
strokeWidth={1.75}
className="size-3.5"
/>

View file

@ -52,6 +52,8 @@ import {
import { inventoryRowMatches, tokenizeQuery } from "./lib/inventory-search";
import {
buildDiscoverRows,
detectResultFormat,
isUnslothFinetunable,
matchesCapability,
matchesFormat,
} from "./lib/view-models";
@ -388,8 +390,10 @@ export function ModelsPage() {
discoverRows.filter((row) => {
if (isDatasetMode) return true;
return (
matchesFormat(row.result.isGguf, deferredFormatFilter) &&
matchesCapability(row.capabilities, deferredCapabilityFilter)
matchesFormat(detectResultFormat(row.result), deferredFormatFilter) &&
matchesCapability(row.capabilities, deferredCapabilityFilter) &&
(!activeChannel?.finetunableOnly ||
isUnslothFinetunable(row.result))
);
}),
[
@ -397,6 +401,7 @@ export function ModelsPage() {
isDatasetMode,
deferredFormatFilter,
deferredCapabilityFilter,
activeChannel,
],
);
@ -721,7 +726,7 @@ export function ModelsPage() {
return (
<div className="hub-page flex h-full min-h-0 flex-col">
<div className="mx-auto flex w-full max-w-[1180px] flex-1 min-h-0 flex-col gap-6 px-5 pt-8 pb-16 sm:px-9 sm:pt-10 sm:pb-24">
<div className="mx-auto flex w-full max-w-[1180px] flex-1 min-h-0 flex-col gap-6 px-5 pt-8 pb-6 sm:px-9 sm:pt-10 sm:pb-8">
<ModelsHeader
cachedCount={effectiveCachedRows.length}
localCount={effectiveLocalRows.length}
@ -759,7 +764,7 @@ export function ModelsPage() {
<div className="flex min-h-0 flex-1 flex-col lg:grid lg:grid-cols-[360px_minmax(0,1fr)] xl:grid-cols-[400px_minmax(0,1fr)] 2xl:grid-cols-[440px_minmax(0,1fr)]">
<div
className={cn(
"hub-side-surface flex min-h-0 min-w-0 flex-1 flex-col border-b border-border lg:flex-initial lg:border-b-0 lg:border-r",
"hub-side-surface flex min-h-0 min-w-0 flex-1 flex-col border-b border-sidebar-border lg:flex-initial lg:border-b-0 lg:border-r lg:border-sidebar-border",
mobileInspectorOpen && "hidden lg:flex",
)}
>

View file

@ -15,6 +15,7 @@
--format-gguf: #60a5fa;
--format-checkpoint: #f472b6;
--format-adapter: #8b5cf6;
--format-mlx: #f59e0b;
--status-warning: #eab308;
--status-danger: #ef4444;
--status-success: #11b686;
@ -24,6 +25,7 @@
--format-gguf: #60a5fa;
--format-checkpoint: #f472b6;
--format-adapter: #a78bfa;
--format-mlx: #fbbf24;
--status-warning: #fbbf24;
--status-danger: #ef4444;
--status-success: #11b686;
@ -33,6 +35,7 @@
--color-format-gguf: var(--format-gguf);
--color-format-checkpoint: var(--format-checkpoint);
--color-format-adapter: var(--format-adapter);
--color-format-mlx: var(--format-mlx);
--color-status-warning: var(--status-warning);
--color-status-danger: var(--status-danger);
--color-status-success: var(--status-success);
@ -734,7 +737,7 @@ html:not(.dark) .hub-page [data-hub-scroll="true"] {
}
html.dark .hub-page [data-hub-scroll="true"] {
scrollbar-color: oklch(0.55 0 0 / 0.16) transparent;
scrollbar-color: oklch(0.72 0 0 / 0.3) transparent;
}
html:not(.dark) .hub-page [data-hub-scroll="true"]::-webkit-scrollbar-thumb {
@ -742,7 +745,7 @@ html:not(.dark) .hub-page [data-hub-scroll="true"]::-webkit-scrollbar-thumb {
}
html.dark .hub-page [data-hub-scroll="true"]::-webkit-scrollbar-thumb {
background: oklch(0.55 0 0 / 0.16);
background: oklch(0.72 0 0 / 0.3);
}
/* While a modal dialog is open (delete confirmation, transport-mode swap, the

View file

@ -26,6 +26,8 @@ export interface ChannelPreset {
idSuffix?: string;
format: ModelFormatFilter;
sort: HfSortKey;
// Keep only formats Unsloth can fine-tune (drops fp8, nvfp4, w4a16, etc.).
finetunableOnly?: boolean;
}
export const CHANNEL_PRESETS: readonly ChannelPreset[] = [
@ -33,7 +35,7 @@ export const CHANNEL_PRESETS: readonly ChannelPreset[] = [
id: "unsloth-trending",
label: "Unsloth Trending",
icon: SparklesIcon,
hint: "Most trending models published by Unsloth.",
hint: "Trending Unsloth models.",
owner: "unsloth",
format: "gguf",
sort: "trendingScore",
@ -42,7 +44,7 @@ export const CHANNEL_PRESETS: readonly ChannelPreset[] = [
id: "unsloth-latest",
label: "Latest Unsloth",
icon: NewReleasesIcon,
hint: "Freshly released models from the Unsloth channel.",
hint: "Newest Unsloth releases.",
owner: "unsloth",
format: "all",
sort: "lastModified",
@ -51,12 +53,11 @@ export const CHANNEL_PRESETS: readonly ChannelPreset[] = [
id: "unsloth-safetensors",
label: "Fine-tune ready",
icon: SlidersHorizontalIcon,
hint: "Latest Unsloth bnb-4bit checkpoints ready to fine-tune.",
hint: "Checkpoints ready to fine-tune.",
owner: "unsloth",
query: "bnb-4bit",
idSuffix: "-bnb-4bit",
format: "checkpoint",
sort: "lastModified",
finetunableOnly: true,
},
];

View file

@ -3,12 +3,13 @@
export type FormatFilterModelFormat =
| "gguf"
| "mlx"
| "safetensors"
| "adapter"
| "checkpoint"
| "unknown";
export type FormatFilterValue = "all" | "gguf" | "checkpoint";
export type FormatFilterValue = "all" | "gguf" | "checkpoint" | "mlx";
export function matchesFormat(
modelFormat: boolean | FormatFilterModelFormat | null | undefined,
@ -22,5 +23,40 @@ export function matchesFormat(
: "safetensors"
: modelFormat;
if (formatFilter === "gguf") return normalized === "gguf";
if (formatFilter === "mlx") return normalized === "mlx";
return normalized === "safetensors" || normalized === "checkpoint";
}
// Discover results carry an isGguf flag plus HF tags/library. MLX repos are
// tagged "mlx" or use library_name "mlx", so detect those before falling back
// to safetensors.
export function detectResultFormat(result: {
isGguf: boolean;
tags?: string[];
libraryName?: string;
}): FormatFilterModelFormat {
if (result.isGguf) return "gguf";
if (
result.libraryName?.toLowerCase() === "mlx" ||
result.tags?.some((tag) => tag.toLowerCase() === "mlx")
) {
return "mlx";
}
return "safetensors";
}
// Inference-only quant formats Unsloth cannot fine-tune. Matched on the repo
// name since the search listing often omits the quant config.
const NON_FINETUNABLE_NAME =
/(?:^|[-_/.])(?:fp8|nvfp4|mxfp4|w4a16|w8a8|w8a16|int4|int8|gptq|awq|mobile|litert|tflite)(?:[-_/.]|$)/i;
// Quant methods Unsloth can fine-tune: full precision (none) or bitsandbytes.
const FINETUNABLE_QUANT = new Set(["bitsandbytes", "bnb", "bnb_4bit"]);
export function isUnslothFinetunable(result: {
id: string;
quantMethod?: string;
}): boolean {
if (NON_FINETUNABLE_NAME.test(result.id)) return false;
const quant = result.quantMethod?.toLowerCase();
return !quant || FINETUNABLE_QUANT.has(quant);
}

View file

@ -18,7 +18,11 @@ import {
} from "./model-capabilities";
import { ownerOf, repoOf } from "@/features/hub/lib/format";
import { estimateSizeFromDtypes, isGgufLike } from "./hf-model-meta";
export { matchesFormat } from "./format-filters";
export {
matchesFormat,
detectResultFormat,
isUnslothFinetunable,
} from "./format-filters";
export {
formatLocalUpdated,
localSourceLabel,
@ -46,6 +50,7 @@ export const FORMAT_FILTER_OPTIONS: ReadonlyArray<{
{ value: "all", label: "All formats" },
{ value: "gguf", label: "GGUF" },
{ value: "checkpoint", label: "Checkpoints" },
{ value: "mlx", label: "MLX" },
];
export function formatPipelineTag(tag: string | undefined): string | null {

View file

@ -24,7 +24,7 @@ export type ResourceTypeFilter = "models" | "datasets";
export type HubModelType = "text" | "vision" | "audio" | "embeddings";
export type ModelFormatFilter = "all" | "gguf" | "checkpoint";
export type ModelFormatFilter = "all" | "gguf" | "checkpoint" | "mlx";
export type CapabilityFilter = "all" | CapabilityKey;

View file

@ -1,15 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { publicAssetUrl } from "@/components/mascot-img";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getAuthToken } from "@/features/auth";
import { cn } from "@/lib/utils";
import { useT } from "@/i18n";
import { toastError, toastSuccess } from "@/shared/toast";
import { Camera01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useMemo, useRef, useState } from "react";
import { SLOTH_AVATARS } from "../sloth-avatars";
import { decodeJwtSubject } from "../utils/jwt-subject";
import { resizeImageFileToDataUrl } from "../utils/resize-image-file";
import { useUserProfileStore } from "../stores/user-profile-store";
@ -17,7 +20,11 @@ import { UserAvatar } from "./user-avatar";
const PROFILE_STORAGE_KEY = "unsloth_user_profile";
function readPersistedProfile(): { displayName: string; avatarDataUrl: string | null } | null {
function readPersistedProfile(): {
displayName: string;
nickname: string;
avatarDataUrl: string | null;
} | null {
try {
const raw = window.localStorage.getItem(PROFILE_STORAGE_KEY);
if (!raw) return null;
@ -27,10 +34,15 @@ function readPersistedProfile(): { displayName: string; avatarDataUrl: string |
// Zustand persist shape: { state: {...}, version }
const maybeState = "state" in parsed ? (parsed as { state?: unknown }).state : parsed;
if (!maybeState || typeof maybeState !== "object") return null;
const state = maybeState as { displayName?: unknown; avatarDataUrl?: unknown };
const state = maybeState as {
displayName?: unknown;
nickname?: unknown;
avatarDataUrl?: unknown;
};
return {
displayName: typeof state.displayName === "string" ? state.displayName : "",
nickname: typeof state.nickname === "string" ? state.nickname : "",
avatarDataUrl: typeof state.avatarDataUrl === "string" ? state.avatarDataUrl : null,
};
} catch {
@ -41,12 +53,17 @@ function readPersistedProfile(): { displayName: string; avatarDataUrl: string |
export function ProfilePersonalizationPanel() {
const t = useT();
const displayName = useUserProfileStore((s) => s.displayName);
const nickname = useUserProfileStore((s) => s.nickname);
const avatarDataUrl = useUserProfileStore((s) => s.avatarDataUrl);
const setDisplayName = useUserProfileStore((s) => s.setDisplayName);
const setNickname = useUserProfileStore((s) => s.setNickname);
const setAvatarDataUrl = useUserProfileStore((s) => s.setAvatarDataUrl);
const avatarShape = useUserProfileStore((s) => s.avatarShape);
const setAvatarShape = useUserProfileStore((s) => s.setAvatarShape);
const [imageError, setImageError] = useState<string | null>(null);
const [draftName, setDraftName] = useState(displayName);
const [draftNickname, setDraftNickname] = useState(nickname);
const fileInputRef = useRef<HTMLInputElement>(null);
const sessionSub = decodeJwtSubject(getAuthToken()) ?? "";
@ -55,6 +72,10 @@ export function ProfilePersonalizationPanel() {
() => draftName.trim() !== displayName.trim(),
[draftName, displayName],
);
const hasNicknameChanges = useMemo(
() => draftNickname.trim() !== nickname.trim(),
[draftNickname, nickname],
);
const saveName = () => {
const trimmed = draftName.trim();
@ -73,21 +94,42 @@ export function ProfilePersonalizationPanel() {
}
};
const saveNickname = () => {
const trimmed = draftNickname.trim();
if (trimmed !== draftNickname) setDraftNickname(trimmed);
if (trimmed !== nickname) {
setNickname(trimmed);
const persisted = readPersistedProfile();
if (persisted && persisted.nickname === trimmed) {
toastSuccess(t("settings.profile.nicknameSaved"));
} else {
toastError(
t("settings.profile.namePersistErrorTitle"),
t("settings.profile.namePersistErrorDescription"),
);
}
}
};
// Persist an avatar value (data URL or asset URL) and toast the result.
const applyAvatar = (value: string) => {
setAvatarDataUrl(value);
const persisted = readPersistedProfile();
if (persisted && persisted.avatarDataUrl === value) {
toastSuccess(t("settings.profile.photoUpdated"));
} else {
toastError(
t("settings.profile.photoPersistErrorTitle"),
t("settings.profile.photoPersistErrorDescription"),
);
}
};
const onPickFile = async (file: File | undefined) => {
if (!file) return;
setImageError(null);
try {
const dataUrl = await resizeImageFileToDataUrl(file);
setAvatarDataUrl(dataUrl);
const persisted = readPersistedProfile();
if (persisted && persisted.avatarDataUrl === dataUrl) {
toastSuccess(t("settings.profile.photoUpdated"));
} else {
toastError(
t("settings.profile.photoPersistErrorTitle"),
t("settings.profile.photoPersistErrorDescription"),
);
}
applyAvatar(await resizeImageFileToDataUrl(file));
} catch (e) {
const message =
e instanceof Error ? e.message : t("settings.profile.imageUseError");
@ -96,6 +138,12 @@ export function ProfilePersonalizationPanel() {
}
};
// Use a bundled sloth sticker as the avatar.
const pickSloth = (path: string) => {
setImageError(null);
applyAvatar(publicAssetUrl(path));
};
return (
<div className="mx-auto flex w-full max-w-[640px] flex-col items-center gap-6 rounded-2xl border border-border/70 bg-muted/10 px-8 py-7">
<div className="relative">
@ -151,6 +199,90 @@ export function ProfilePersonalizationPanel() {
</div>
</div>
<div className="flex w-full max-w-[560px] flex-col gap-2">
<Label htmlFor="profile-nickname" className="text-xs font-medium text-muted-foreground">
{t("settings.profile.nickname")}
</Label>
<div className="flex items-center gap-2">
<Input
id="profile-nickname"
type="text"
value={draftNickname}
onChange={(e) => setDraftNickname(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
saveNickname();
}
}}
autoComplete="off"
placeholder={t("settings.profile.nicknamePlaceholder")}
className="h-10 min-w-0 flex-1 rounded-full text-sm"
/>
<Button type="button" size="sm" className="h-10 px-5" onClick={saveNickname} disabled={!hasNicknameChanges}>
{t("common.save")}
</Button>
</div>
</div>
<div className="flex w-full max-w-[560px] flex-col gap-2">
<Label className="text-xs font-medium text-muted-foreground">
{t("settings.profile.avatarShape")}
</Label>
<div className="inline-flex w-fit items-center gap-1 rounded-full border border-border/70 bg-muted/40 p-1">
{(["circle", "rounded"] as const).map((shape) => (
<button
key={shape}
type="button"
onClick={() => setAvatarShape(shape)}
aria-pressed={avatarShape === shape}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-medium transition-colors",
avatarShape === shape
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{shape === "circle"
? t("settings.profile.avatarShapeCircle")
: t("settings.profile.avatarShapeRounded")}
</button>
))}
</div>
</div>
<div className="flex w-full max-w-[560px] flex-col gap-2">
<Label className="text-xs font-medium text-muted-foreground">
{t("settings.profile.chooseSloth")}
</Label>
<div className="grid grid-cols-7 gap-2 sm:grid-cols-9">
{SLOTH_AVATARS.map((path) => {
const url = publicAssetUrl(path);
const selected = avatarDataUrl === url;
// Readable accessible name from the filename, e.g. "sloth yay".
const label =
path.split("/").pop()?.replace(/\.png$/i, "").replace(/^large\s+/i, "").trim() ??
"sloth";
return (
<button
key={path}
type="button"
onClick={() => pickSloth(path)}
aria-pressed={selected}
aria-label={label}
title={label}
className={cn(
"relative aspect-square overflow-hidden rounded-full bg-muted ring-1 ring-border transition hover:ring-primary/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
selected && "ring-2 ring-primary",
)}
>
<img src={url} alt="" loading="lazy" className="size-full object-cover" />
</button>
);
})}
</div>
</div>
{imageError ? (
<p className="w-full text-xs text-destructive" role="alert">
{imageError}

View file

@ -3,12 +3,18 @@
import { cn } from "@/lib/utils";
import { avatarBgStyle, initialsFromName } from "../utils/avatar-initials";
import {
useUserProfileStore,
type AvatarShape,
} from "../stores/user-profile-store";
type UserAvatarProps = {
name: string;
imageUrl: string | null;
size: "sm" | "md" | "lg";
className?: string;
/** Override the stored shape preference (defaults to the user's setting). */
shape?: AvatarShape;
};
const SIZE: Record<"sm" | "md" | "lg", string> = {
@ -18,12 +24,20 @@ const SIZE: Record<"sm" | "md" | "lg", string> = {
lg: "size-[106px] text-[1.65rem]",
};
export function UserAvatar({ name, imageUrl, size, className }: UserAvatarProps) {
// Percentage radius keeps the rounded-rectangle proportional across sizes.
const SHAPE: Record<AvatarShape, string> = {
circle: "rounded-full",
rounded: "rounded-[22%]",
};
export function UserAvatar({ name, imageUrl, size, className, shape }: UserAvatarProps) {
const label = initialsFromName(name);
const storedShape = useUserProfileStore((s) => s.avatarShape);
const shapeClass = SHAPE[shape ?? storedShape];
if (imageUrl) {
return (
<span className={cn("relative inline-flex shrink-0 overflow-hidden rounded-full", SIZE[size], className)}>
<span className={cn("relative inline-flex shrink-0 overflow-hidden bg-transparent", shapeClass, SIZE[size], className)}>
<img src={imageUrl} alt="" className="size-full object-cover" />
</span>
);
@ -33,7 +47,8 @@ export function UserAvatar({ name, imageUrl, size, className }: UserAvatarProps)
<span
style={avatarBgStyle()}
className={cn(
"inline-flex shrink-0 items-center justify-center rounded-full font-semibold text-white",
"inline-flex shrink-0 items-center justify-center font-semibold text-white",
shapeClass,
SIZE[size],
className,
)}

View file

@ -7,13 +7,17 @@ import { useUserProfileStore } from "../stores/user-profile-store";
export function useEffectiveProfile() {
const displayName = useUserProfileStore((s) => s.displayName);
const nickname = useUserProfileStore((s) => s.nickname);
const avatarDataUrl = useUserProfileStore((s) => s.avatarDataUrl);
const sessionSub = decodeJwtSubject(getAuthToken());
const dn = displayName.trim();
// Name to address the user by: nickname, else first name, else login id.
const addressName = nickname.trim() || dn.split(/\s+/)[0] || sessionSub || "";
return {
sessionSub,
displayTitle: dn || "Unsloth",
addressName,
avatarDataUrl,
};
}

View file

@ -0,0 +1,36 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
// Curated list of sloth emoji stickers offered as profile pictures.
//
// The full `public/Sloth emojis` folder has ~38 PNGs, but many are non-square
// or carry heavy whitespace on one or more edges, which crops badly inside the
// round avatar frame. This list is the subset that is (a) effectively square
// (aspect ratio within ~10% of 1:1) and (b) low-whitespace on every edge, so
// each one fills the avatar circle cleanly. Exact duplicates are de-duped.
//
// Paths are relative to the public folder; resolve with `publicAssetUrl(...)`
// before using as an <img> src so spaces and subpath deploys are handled.
export const SLOTH_AVATARS: readonly string[] = [
"Sloth emojis/large sloth yay.png",
"Sloth emojis/large sloth heart.png",
"Sloth emojis/large sloth wave.png",
"Sloth emojis/large sloth thumbs.png",
"Sloth emojis/large sloth cheeky.png",
"Sloth emojis/large sloth glasses.png",
"Sloth emojis/large sloth fire.png",
"Sloth emojis/large sloth drink.png",
"Sloth emojis/large sloth sad.png",
"Sloth emojis/Large sloth Question mark.png",
"Sloth emojis/sloth shy large.png",
"Sloth emojis/sloth shock large.png",
"Sloth emojis/sloth sir large.png",
"Sloth emojis/sloth huglove large.png",
"Sloth emojis/sloth headphones.png",
"Sloth emojis/sloth pc square.png",
"Sloth emojis/sloth on phone.png",
"Sloth emojis/sloth magnify final.png",
"Sloth emojis/Sloth loca pc.png",
"Sloth emojis/UnSloth GPU Front square.png",
"Sloth emojis/UnSloth Sparkling large.png",
];

View file

@ -4,20 +4,32 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
export type AvatarShape = "circle" | "rounded";
export interface UserProfileState {
displayName: string;
// Preferred name used to address the user (greetings, etc.).
nickname: string;
avatarDataUrl: string | null;
// Avatar outline: full circle or rounded rectangle.
avatarShape: AvatarShape;
setDisplayName: (displayName: string) => void;
setNickname: (nickname: string) => void;
setAvatarDataUrl: (avatarDataUrl: string | null) => void;
setAvatarShape: (avatarShape: AvatarShape) => void;
}
export const useUserProfileStore = create<UserProfileState>()(
persist(
(set) => ({
displayName: "",
nickname: "",
avatarDataUrl: null,
avatarShape: "circle",
setDisplayName: (displayName) => set({ displayName }),
setNickname: (nickname) => set({ nickname }),
setAvatarDataUrl: (avatarDataUrl) => set({ avatarDataUrl }),
setAvatarShape: (avatarShape) => set({ avatarShape }),
}),
{ name: "unsloth_user_profile" },
),

View file

@ -2,6 +2,9 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
const MAX_EDGE = 256;
// Smallest edge we shrink a transparent image to before giving up on WebP.
const MIN_EDGE = 96;
const EDGE_STEP = 32;
const MAX_BYTES = 380_000;
const MAX_DATA_URL_LENGTH = Math.floor(MAX_BYTES * 1.35);
const JPEG_QUALITY_START = 0.88;
@ -50,6 +53,33 @@ function encodeCanvasWithinLimit(
return dataUrl.length <= MAX_DATA_URL_LENGTH ? dataUrl : null;
}
// Lossless PNG keeps alpha and, unlike WebP encoding, works in every browser
// (Safari cannot encode WebP). Size is controlled only by dimensions.
function encodePngWithinLimit(canvas: HTMLCanvasElement): string | null {
const dataUrl = canvas.toDataURL("image/png");
if (!dataUrl.startsWith("data:image/png")) return null;
return dataUrl.length <= MAX_DATA_URL_LENGTH ? dataUrl : null;
}
// Draw the image onto a canvas scaled to fit within maxEdge.
function drawScaled(
img: HTMLImageElement,
w: number,
h: number,
maxEdge: number,
): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D; cw: number; ch: number } {
const scale = Math.min(1, maxEdge / Math.max(w, h));
const cw = Math.max(1, Math.round(w * scale));
const ch = Math.max(1, Math.round(h * scale));
const canvas = document.createElement("canvas");
canvas.width = cw;
canvas.height = ch;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas not available");
ctx.drawImage(img, 0, 0, cw, ch);
return { canvas, ctx, cw, ch };
}
/** Downscale the image, preserving transparency, to stay localStorage-friendly. */
export async function resizeImageFileToDataUrl(file: File): Promise<string> {
const img = await loadImage(file);
@ -57,24 +87,24 @@ export async function resizeImageFileToDataUrl(file: File): Promise<string> {
const h = img.naturalHeight;
if (!w || !h) throw new Error("Invalid image dimensions");
const scale = Math.min(1, MAX_EDGE / Math.max(w, h));
const cw = Math.max(1, Math.round(w * scale));
const ch = Math.max(1, Math.round(h * scale));
const base = drawScaled(img, w, h, MAX_EDGE);
const hasTransparency = canvasHasTransparency(base.ctx, base.cw, base.ch);
const canvas = document.createElement("canvas");
canvas.width = cw;
canvas.height = ch;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas not available");
ctx.drawImage(img, 0, 0, cw, ch);
const hasTransparency = canvasHasTransparency(ctx, cw, ch);
if (hasTransparency) {
const webpDataUrl = encodeCanvasWithinLimit(canvas, "image/webp", WEBP_QUALITY_START);
if (webpDataUrl) return webpDataUrl;
// Keep alpha, shrinking to fit. WebP is smallest where supported; PNG is
// the universal fallback (Safari cannot encode WebP). Never JPEG, which
// would paint a background behind a transparent image.
for (let edge = MAX_EDGE; edge >= MIN_EDGE; edge -= EDGE_STEP) {
const { canvas } = edge === MAX_EDGE ? base : drawScaled(img, w, h, edge);
const webpDataUrl = encodeCanvasWithinLimit(canvas, "image/webp", WEBP_QUALITY_START);
if (webpDataUrl) return webpDataUrl;
const pngDataUrl = encodePngWithinLimit(canvas);
if (pngDataUrl) return pngDataUrl;
}
throw new Error("Image is still too large after compression. Try a smaller file.");
}
const jpegDataUrl = encodeCanvasWithinLimit(canvas, "image/jpeg", JPEG_QUALITY_START);
const jpegDataUrl = encodeCanvasWithinLimit(base.canvas, "image/jpeg", JPEG_QUALITY_START);
if (jpegDataUrl) return jpegDataUrl;
throw new Error("Image is still too large after compression. Try a smaller file.");

View file

@ -0,0 +1,218 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
deleteChatItem,
unarchiveChatItem,
useChatPreferencesStore,
useChatRuntimeStore,
useChatSidebarItems,
type SidebarItem,
} from "@/features/chat";
import { toast } from "@/lib/toast";
import { ArchiveRestoreIcon, Delete02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useNavigate, useRouterState } from "@tanstack/react-router";
import { useState } from "react";
import { useSettingsDialogStore } from "../stores/settings-dialog-store";
function formatCreatedAt(ms: number): string {
return new Date(ms).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
});
}
export function ArchivedChatsDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { archivedItems } = useChatSidebarItems({ requireMessages: false });
const navigate = useNavigate();
const closeSettings = useSettingsDialogStore((s) => s.closeDialog);
const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId);
// Open chat id from the route. Compare panes do not write the store, so the
// pair id only lives in the search params; mirror how the sidebar reads it.
const openChatId = useRouterState({
select: (s) => {
if (!s.location.pathname.startsWith("/chat")) return undefined;
const search = s.location.search as Record<string, string | undefined>;
return search.thread ?? search.compare ?? storeThreadId ?? undefined;
},
});
const confirmDeleteChats = useChatPreferencesStore(
(s) => s.confirmDeleteChats,
);
const [confirmingDelete, setConfirmingDelete] = useState<SidebarItem | null>(
null,
);
// Open an archived chat: leave it archived, just navigate to it.
function openChat(item: SidebarItem) {
navigate({
to: "/chat",
search:
item.type === "single" ? { thread: item.id } : { compare: item.id },
});
onOpenChange(false);
closeSettings();
}
async function handleUnarchive(item: SidebarItem) {
try {
await unarchiveChatItem(item);
toast.success("Chat unarchived");
} catch (err) {
toast.error("Failed to unarchive chat", {
description: err instanceof Error ? err.message : undefined,
});
}
}
async function handleDelete(item: SidebarItem) {
try {
// Pass the open chat id (single or compare) so deleting it resets nav.
await deleteChatItem(item, openChatId, (view) => {
navigate({
to: "/chat",
search: item.projectId
? { project: item.projectId }
: { new: view.newThreadNonce },
});
});
toast.success("Chat deleted");
} catch (err) {
toast.error("Failed to delete chat", {
description: err instanceof Error ? err.message : undefined,
});
}
}
function requestDelete(item: SidebarItem) {
if (confirmDeleteChats) setConfirmingDelete(item);
else void handleDelete(item);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Archived chats</DialogTitle>
</DialogHeader>
{archivedItems.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No archived chats.
</p>
) : (
<div className="max-h-[60vh] overflow-y-auto">
<div className="flex items-center gap-4 border-b border-border/60 px-1 pb-2 text-xs font-semibold text-foreground">
<span className="flex-1">Name</span>
<span className="w-32 shrink-0">Date created</span>
<span className="w-16 shrink-0" />
</div>
{archivedItems.map((item) => (
<div
key={item.id}
className="group flex items-center gap-4 border-b border-border/40 px-1 py-2.5 text-sm last:border-0"
>
<button
type="button"
onClick={() => openChat(item)}
className="min-w-0 flex-1 truncate text-left text-primary hover:underline"
title={item.title}
>
{item.title}
</button>
<span className="w-32 shrink-0 text-muted-foreground tabular-nums">
{formatCreatedAt(item.createdAt)}
</span>
<span className="flex w-16 shrink-0 items-center justify-end gap-1">
<button
type="button"
onClick={() => void handleUnarchive(item)}
aria-label="Unarchive chat"
title="Unarchive"
className="inline-flex size-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<HugeiconsIcon
icon={ArchiveRestoreIcon}
strokeWidth={1.75}
className="size-4"
/>
</button>
<button
type="button"
onClick={() => requestDelete(item)}
aria-label="Delete chat"
title="Delete"
className="inline-flex size-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<HugeiconsIcon
icon={Delete02Icon}
strokeWidth={1.75}
className="size-4"
/>
</button>
</span>
</div>
))}
</div>
)}
</DialogContent>
<AlertDialog
open={confirmingDelete !== null}
onOpenChange={(o) => {
if (!o) setConfirmingDelete(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete chat</AlertDialogTitle>
<AlertDialogDescription>
Delete{" "}
<span className="font-medium text-foreground">
&quot;{confirmingDelete?.title}&quot;
</span>
? This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
const item = confirmingDelete;
setConfirmingDelete(null);
if (item) void handleDelete(item);
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
);
}

View file

@ -0,0 +1,70 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { getAuthToken } from "@/features/auth";
import { useT } from "@/i18n";
import { apiUrl } from "@/lib/api-base";
import { useEffect, useState } from "react";
import { SettingsRow } from "./settings-row";
import { SettingsSection } from "./settings-section";
type ApiObject = Record<string, unknown>;
async function fetchStudioVersions(): Promise<{
packageVersion: string | null;
studioVersion: string | null;
}> {
try {
const token = getAuthToken();
const headers = new Headers();
if (token) headers.set("Authorization", `Bearer ${token}`);
const res = await fetch(apiUrl("/api/health"), { headers });
if (!res.ok) {
return { packageVersion: null, studioVersion: null };
}
const data = (await res.json()) as ApiObject;
const packageVersion = data.version;
const studioVersion = data.studio_version;
return {
packageVersion:
typeof packageVersion === "string" ? packageVersion : null,
studioVersion: typeof studioVersion === "string" ? studioVersion : null,
};
} catch {
return { packageVersion: null, studioVersion: null };
}
}
// Shared "Unsloth" version block, shown in both General and About.
export function StudioVersionSection() {
const t = useT();
const [packageVersion, setPackageVersion] = useState("dev");
const [studioVersion, setStudioVersion] = useState("dev");
useEffect(() => {
let canceled = false;
fetchStudioVersions().then((next) => {
if (canceled) return;
if (next.packageVersion) setPackageVersion(next.packageVersion);
if (next.studioVersion) setStudioVersion(next.studioVersion);
});
return () => {
canceled = true;
};
}, []);
return (
<SettingsSection title="Unsloth">
<SettingsRow label={t("settings.about.studioVersion")}>
<code className="font-mono text-xs text-muted-foreground">
{studioVersion}
</code>
</SettingsRow>
<SettingsRow label={t("settings.about.packageVersion")}>
<code className="font-mono text-xs text-muted-foreground">
{packageVersion}
</code>
</SettingsRow>
</SettingsSection>
);
}

View file

@ -20,11 +20,24 @@ interface SettingsDialogState {
// previous-focus capture, leaving focus on <body> after close. We restore
// explicitly via onCloseAutoFocus.
opener: HTMLElement | null;
// Set when something asks to jump straight to the archived chats list (the
// archive toast). ChatTab consumes it to open the dialog, then clears it.
archivedChatsRequested: boolean;
openDialog: (tab?: SettingsTab) => void;
openArchivedChats: () => void;
consumeArchivedChatsRequest: () => void;
closeDialog: () => void;
setActiveTab: (tab: SettingsTab) => void;
}
function captureOpener(): HTMLElement | null {
return typeof document !== "undefined" &&
document.activeElement instanceof HTMLElement &&
document.activeElement !== document.body
? document.activeElement
: null;
}
const ACTIVE_TAB_KEY = "unsloth_settings_active_tab";
function loadInitialTab(): SettingsTab {
@ -53,17 +66,21 @@ export const useSettingsDialogStore = create<SettingsDialogState>((set) => ({
open: false,
activeTab: loadInitialTab(),
opener: null,
archivedChatsRequested: false,
openDialog: (tab) =>
set((state) => ({
open: true,
activeTab: tab ?? state.activeTab,
opener:
typeof document !== "undefined" &&
document.activeElement instanceof HTMLElement &&
document.activeElement !== document.body
? document.activeElement
: null,
opener: captureOpener(),
})),
openArchivedChats: () =>
set({
open: true,
activeTab: "chat",
archivedChatsRequested: true,
opener: captureOpener(),
}),
consumeArchivedChatsRequest: () => set({ archivedChatsRequested: false }),
// Do NOT clear `opener` here. onCloseAutoFocus runs on the next render
// pass after `open: false` lands, so the opener must still be readable
// from the store at that point. The next openDialog() overwrites it.

View file

@ -19,6 +19,7 @@ import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useState } from "react";
import { SettingsRow } from "../components/settings-row";
import { SettingsSection } from "../components/settings-section";
import { StudioVersionSection } from "../components/studio-version-section";
import {
type UpdateInstallSource,
UpdateStudioInstructions,
@ -44,31 +45,6 @@ function isUpdateInstallSource(value: unknown): value is UpdateInstallSource {
);
}
async function fetchStudioVersions(): Promise<{
packageVersion: string | null;
studioVersion: string | null;
}> {
try {
const token = getAuthToken();
const headers = new Headers();
if (token) headers.set("Authorization", `Bearer ${token}`);
const res = await fetch(apiUrl("/api/health"), { headers });
if (!res.ok) {
return { packageVersion: null, studioVersion: null };
}
const data = (await res.json()) as ApiObject;
const packageVersion = data.version;
const studioVersion = data.studio_version;
return {
packageVersion:
typeof packageVersion === "string" ? packageVersion : null,
studioVersion: typeof studioVersion === "string" ? studioVersion : null,
};
} catch {
return { packageVersion: null, studioVersion: null };
}
}
async function fetchInstallSource(): Promise<UpdateInstallSource> {
if (isTauri) {
return "unknown";
@ -99,8 +75,6 @@ export function AboutTab() {
const deviceType = usePlatformStore((s) => s.deviceType);
const defaultShell = deviceType === "windows" ? "windows" : "unix";
const [shutdownOpen, setShutdownOpen] = useState(false);
const [packageVersion, setPackageVersion] = useState("dev");
const [studioVersion, setStudioVersion] = useState("dev");
const [installSource, setInstallSource] = useState<
UpdateInstallSource | "loading"
>("loading");
@ -108,18 +82,6 @@ export function AboutTab() {
useEffect(() => {
let canceled = false;
fetchStudioVersions().then((nextVersions) => {
if (canceled) {
return;
}
if (nextVersions.packageVersion) {
setPackageVersion(nextVersions.packageVersion);
}
if (nextVersions.studioVersion) {
setStudioVersion(nextVersions.studioVersion);
}
});
fetchInstallSource().then((nextInstallSource) => {
if (!canceled) {
setInstallSource(nextInstallSource);
@ -142,18 +104,7 @@ export function AboutTab() {
</p>
</header>
<SettingsSection title="Unsloth">
<SettingsRow label={t("settings.about.studioVersion")}>
<code className="font-mono text-xs text-muted-foreground">
{studioVersion}
</code>
</SettingsRow>
<SettingsRow label={t("settings.about.packageVersion")}>
<code className="font-mono text-xs text-muted-foreground">
{packageVersion}
</code>
</SettingsRow>
</SettingsSection>
<StudioVersionSection />
<SettingsSection title={t("settings.about.updates")}>
<div className="py-2">

View file

@ -30,6 +30,7 @@ import {
downloadChatExport,
importConversationsFromFile,
useChatRuntimeStore,
useChatPreferencesStore,
type PlusMenuItemId,
usePlusMenuPrefsStore,
} from "@/features/chat";
@ -50,6 +51,8 @@ import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { SettingsRow } from "../components/settings-row";
import { SettingsSection } from "../components/settings-section";
import { ArchivedChatsDialog } from "../components/archived-chats-dialog";
import { useSettingsDialogStore } from "../stores/settings-dialog-store";
// Adjustable "+" menu items shown in settings, in display order. Icons mirror
// the ones used in the composer + menu itself.
@ -134,7 +137,21 @@ export function ChatTab() {
const plusPins = usePlusMenuPrefsStore((state) => state.pins);
const togglePlusPin = usePlusMenuPrefsStore((state) => state.togglePin);
const [confirmOpen, setConfirmOpen] = useState(false);
const [archivedOpen, setArchivedOpen] = useState(false);
const [count, setCount] = useState<number | null>(null);
const archivedChatsRequested = useSettingsDialogStore(
(s) => s.archivedChatsRequested,
);
const consumeArchivedChatsRequest = useSettingsDialogStore(
(s) => s.consumeArchivedChatsRequest,
);
// Open the archived list when the archive toast asked to jump here.
useEffect(() => {
if (!archivedChatsRequested) return;
setArchivedOpen(true);
consumeArchivedChatsRequest();
}, [archivedChatsRequested, consumeArchivedChatsRequest]);
const [exporting, setExporting] = useState(false);
const [clearing, setClearing] = useState(false);
const collapseHtmlArtifacts = useChatRuntimeStore(
@ -152,6 +169,12 @@ export function ChatTab() {
const hydratePersistedSettings = useChatRuntimeStore(
(state) => state.hydratePersistedSettings,
);
const confirmDeleteChats = useChatPreferencesStore(
(state) => state.confirmDeleteChats,
);
const setConfirmDeleteChats = useChatPreferencesStore(
(state) => state.setConfirmDeleteChats,
);
useEffect(() => {
void countAllChats().then(setCount);
@ -303,6 +326,29 @@ export function ChatTab() {
</SettingsSection>
<SettingsSection title={t("settings.chat.data")}>
<SettingsRow
label="Archived chats"
description="View and manage chats you have archived."
>
<Button
variant="outline"
size="sm"
onClick={() => setArchivedOpen(true)}
>
Manage
</Button>
</SettingsRow>
<SettingsRow
label="Confirm before deleting"
description="Ask for confirmation before a chat is deleted. Turn off to delete instantly."
>
<Switch
checked={confirmDeleteChats}
onCheckedChange={setConfirmDeleteChats}
/>
</SettingsRow>
<SettingsRow
label={t("settings.chat.exportHistory")}
description={t("settings.chat.exportHistoryDescription")}
@ -405,6 +451,8 @@ export function ChatTab() {
<SettingsRow
destructive
// divide-y already draws the row separator; drop the extra border.
className="border-t-0 mt-0 pt-3"
label={t("settings.chat.clearAllChats")}
description={
count === null
@ -429,6 +477,8 @@ export function ChatTab() {
</SettingsRow>
</SettingsSection>
<ArchivedChatsDialog open={archivedOpen} onOpenChange={setArchivedOpen} />
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent className="max-w-md">
<DialogHeader>

View file

@ -37,6 +37,7 @@ import { useEffect, useRef, useState } from "react";
import { Eye, EyeOff } from "lucide-react";
import { SettingsRow } from "../components/settings-row";
import { SettingsSection } from "../components/settings-section";
import { StudioVersionSection } from "../components/studio-version-section";
// Keys cleared by "Reset all local preferences".
// NEVER include auth/session keys here — clearing them would log the user out
@ -59,6 +60,7 @@ const PREFS_KEYS: string[] = [
"unsloth_tool_call_timeout",
"unsloth_chat_inference_params",
"unsloth_chat_collapsible_state",
"unsloth_chat_preferences",
// Chat presets
"unsloth_chat_custom_presets",
"unsloth_chat_active_preset",
@ -252,6 +254,8 @@ export function GeneralTab() {
</p>
</header>
<StudioVersionSection />
<SettingsSection title={t("settings.general.account")}>
<SettingsRow
label={t("settings.general.huggingFaceToken")}

View file

@ -152,6 +152,13 @@ export const en = {
description: "How your profile appears in Unsloth.",
changePicture: "Change profile picture",
displayName: "Display name",
nickname: "What should Unsloth call you?",
nicknamePlaceholder: "Nickname",
nicknameSaved: "Preferred name saved",
avatarShape: "Profile picture shape",
avatarShapeCircle: "Circle",
avatarShapeRounded: "Rounded",
chooseSloth: "Or pick a sloth",
nameSaved: "Profile name saved",
namePersistErrorTitle: "Could not persist profile name",
namePersistErrorDescription:

View file

@ -139,6 +139,13 @@ export const zhCN = {
description: "更新你在 Unsloth 中显示的个人资料。",
changePicture: "更换头像",
displayName: "显示名称",
nickname: "Unsloth 应该怎么称呼你?",
nicknamePlaceholder: "昵称",
nicknameSaved: "称呼名称已保存",
avatarShape: "头像形状",
avatarShapeCircle: "圆形",
avatarShapeRounded: "圆角矩形",
chooseSloth: "或选择一只树懒",
nameSaved: "个人资料名称已保存",
namePersistErrorTitle: "无法持久保存个人资料名称",
namePersistErrorDescription:

View file

@ -578,6 +578,11 @@
.sidebar-row-action-glyph {
@apply inline-flex size-6 items-center justify-center rounded-full text-sidebar-foreground/55;
}
/* Secondary row action (the pinned-chat unpin button) sits just left of
the primary "…" options button. */
.sidebar-row-action.is-unpin-action {
right: 1.875rem;
}
/* Branch picker chevron buttons sit beside action bar icon buttons
(size-8, rounded-full). Height + radius match for visual
@ -744,7 +749,7 @@
}
.tooltip-compact {
@apply rounded-[9px] border-transparent bg-black px-2.5 py-1.5 text-[11px] font-medium leading-snug text-white shadow-md;
@apply rounded-[11px] border-transparent bg-black px-2.5 py-1.5 text-[11px] font-medium leading-snug text-white shadow-md;
}
/* Dialog popups: borderless; chatbox shadow in light, flat card
@ -1784,6 +1789,12 @@
padding-bottom: 14px !important;
}
/* Downloading state shows a progress bar as the last row; give it a little
extra breathing room below the bar. */
[data-sonner-toast][data-styled='true'].chat-model-load-toast:has([role='progressbar']) {
padding-bottom: 18px !important;
}
[data-sonner-toast][data-styled='true'].chat-model-loaded-toast [data-close-button] {
top: calc(50% - 0.25px) !important;
transform: translateY(-50%) !important;

View file

@ -0,0 +1,34 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import type { IconSvgElement } from "@hugeicons/react";
// Standard chevrons: straight-line shapes shared across dropdown triggers and
// submenu arrows so every menu indicator matches the composer's menus.
export const ChevronDownStandardIcon: IconSvgElement = [
[
"path",
{
d: "M5.99977 9.00005L11.9998 15L17.9998 9",
stroke: "currentColor",
strokeLinecap: "round",
strokeLinejoin: "round",
strokeWidth: "1.5",
key: "0",
},
],
];
export const ChevronRightStandardIcon: IconSvgElement = [
[
"path",
{
d: "M9 6L15 12L9 18",
stroke: "currentColor",
strokeLinecap: "round",
strokeLinejoin: "round",
strokeWidth: "1.5",
key: "0",
},
],
];