Settings: pin and reorder the sidebar navigation

Adds a "Sidebar navigation" section to Settings -> Appearance, above the
existing profile-menu customizer, with the same drag-to-reorder + switch UI.

- New sidebarNav preference: one { id, pinned } entry per navigable row
  (projects, hub, images, train, video, recipes, export), array order = render
  order. Defaults match the shipped layout, so an untouched install is unchanged.
- Unpinning moves a row into the More flyout rather than hiding it, so no page
  becomes unreachable. New chat and Search stay fixed as actions.
- app-sidebar now renders from one navRows descriptor map, so a pinned row and
  its flyout counterpart cannot drift; the More row appears only when something
  is unpinned and highlights off whatever it actually holds.
- Mirrored in the backend PersonalizationCustomization: without it the model's
  extra="ignore" would drop the field, and because sync replaces local state
  with the server's copy once customization is saved, the user's pin order would
  reset on the next sync. The validator dedupes and back-fills like sidebarMenu
  but preserves the client's order, since here order is meaningful.

Frontend typecheck, i18n parity and catalog checks pass; 32 personalization
tests pass, including a round-trip asserting a reordered list survives a save.
This commit is contained in:
michaelhan 2026-07-25 01:17:38 -07:00
commit dc3f794373
10 changed files with 641 additions and 197 deletions

View file

@ -734,6 +734,21 @@ SIDEBAR_MENU_ITEM_DEFAULTS = {
"connections": False,
}
# Navigable sidebar rows the user can pin/reorder; the boolean is each id's
# default pin state, matching the shipped layout. An unpinned row moves into the
# "More" flyout client-side, so it stays reachable.
SIDEBAR_NAV_ITEM_DEFAULTS = {
"projects": True,
"hub": True,
"images": True,
"train": True,
"video": False,
"recipes": False,
"export": False,
}
MAX_SIDEBAR_NAV_INPUT_ITEMS = 4 * len(SIDEBAR_NAV_ITEM_DEFAULTS)
# The sidebarMenu validator below dedupes ids and re-fills any missing ones, so
# the stored list is always exactly one entry per id. Cap the *incoming* list at
# a generous multiple rather than len(defaults): a stale or duplicated payload
@ -766,6 +781,28 @@ def _default_sidebar_menu() -> "list[PersonalizationSidebarMenuItem]":
]
class PersonalizationSidebarNavItem(BaseModel):
model_config = ConfigDict(extra = "ignore")
id: Literal[
"projects",
"hub",
"images",
"train",
"video",
"recipes",
"export",
]
pinned: bool = True
def _default_sidebar_nav() -> "list[PersonalizationSidebarNavItem]":
return [
PersonalizationSidebarNavItem(id = item_id, pinned = pinned)
for item_id, pinned in SIDEBAR_NAV_ITEM_DEFAULTS.items()
]
class PersonalizationCustomization(BaseModel):
model_config = ConfigDict(extra = "ignore")
@ -803,6 +840,12 @@ class PersonalizationCustomization(BaseModel):
default_factory = _default_sidebar_menu,
max_length = MAX_SIDEBAR_MENU_INPUT_ITEMS,
)
# Order matters here: it is the sidebar's render order, so the validator
# preserves the client's sequence and only appends ids it didn't send.
sidebarNav: list[PersonalizationSidebarNavItem] = Field(
default_factory = _default_sidebar_nav,
max_length = MAX_SIDEBAR_NAV_INPUT_ITEMS,
)
@field_validator("sidebarMenu")
@classmethod
@ -818,6 +861,21 @@ class PersonalizationCustomization(BaseModel):
items.append(PersonalizationSidebarMenuItem(id = item_id, visible = visible))
return items
@field_validator("sidebarNav")
@classmethod
def _validate_sidebar_nav(
cls, value: list[PersonalizationSidebarNavItem]
) -> list[PersonalizationSidebarNavItem]:
# Same contract as sidebarMenu: drop duplicate ids (keep the first) and
# re-append missing ones, so the stored list covers every nav row exactly
# once while keeping the client's order.
seen: set[str] = set()
items = [item for item in value if not (item.id in seen or seen.add(item.id))]
for item_id, pinned in SIDEBAR_NAV_ITEM_DEFAULTS.items():
if item_id not in seen:
items.append(PersonalizationSidebarNavItem(id = item_id, pinned = pinned))
return items
class PersonalizationAppearance(BaseModel):
model_config = ConfigDict(extra = "ignore")

View file

@ -18,8 +18,10 @@ from auth.authentication import get_current_subject # noqa: E402
from routes import settings as settings_routes # noqa: E402
from routes.settings import ( # noqa: E402
MAX_SIDEBAR_MENU_INPUT_ITEMS,
MAX_SIDEBAR_NAV_INPUT_ITEMS,
PersonalizationPayload,
SIDEBAR_MENU_ITEM_DEFAULTS,
SIDEBAR_NAV_ITEM_DEFAULTS,
)
@ -144,6 +146,68 @@ def test_customization_sidebar_menu_rejects_pathological_length():
PersonalizationPayload.model_validate(_sidebar(huge))
def _sidebar_nav(items):
return {"appearance": {"customization": {"sidebarNav": items}}}
def test_customization_sidebar_nav_defaults_match_shipped_layout():
# An untouched payload pins the rows the sidebar ships with and leaves the
# rest for the "More" flyout, so a fresh account looks unchanged.
c = PersonalizationPayload().appearance.customization
assert [(i.id, i.pinned) for i in c.sidebarNav] == [
("projects", True),
("hub", True),
("images", True),
("train", True),
("video", False),
("recipes", False),
("export", False),
]
def test_customization_sidebar_nav_preserves_order_and_normalizes():
p = PersonalizationPayload.model_validate(
_sidebar_nav(
[
{"id": "video", "pinned": True},
{"id": "video", "pinned": False},
{"id": "hub", "pinned": False},
]
)
)
# Order is the sidebar's render order, so the client's sequence survives:
# duplicates keep the first entry and unsent ids are appended with defaults.
assert [(i.id, i.pinned) for i in p.appearance.customization.sidebarNav] == [
("video", True),
("hub", False),
("projects", True),
("images", True),
("train", True),
("recipes", False),
("export", False),
]
def test_customization_sidebar_nav_rejects_unknown_id():
with pytest.raises(ValidationError):
PersonalizationPayload.model_validate(_sidebar_nav([{"id": "chats"}]))
def test_customization_sidebar_nav_dedupes_oversized_payload():
ids = list(SIDEBAR_NAV_ITEM_DEFAULTS)
doubled = [{"id": i} for i in ids] + [{"id": i} for i in ids]
assert len(doubled) > len(SIDEBAR_NAV_ITEM_DEFAULTS)
p = PersonalizationPayload.model_validate(_sidebar_nav(doubled))
result = [i.id for i in p.appearance.customization.sidebarNav]
assert result == ids
def test_customization_sidebar_nav_rejects_pathological_length():
huge = [{"id": "hub"} for _ in range(MAX_SIDEBAR_NAV_INPUT_ITEMS + 1)]
with pytest.raises(ValidationError):
PersonalizationPayload.model_validate(_sidebar_nav(huge))
def test_customization_imported_fonts_validated():
ok = PersonalizationPayload.model_validate(
{
@ -379,6 +443,17 @@ def test_personalization_route_roundtrip_real_shape(monkeypatch):
{"id": "chat", "visible": False},
{"id": "connections", "visible": False},
],
# Reordered and partly unpinned, so the round-trip proves the
# sidebar's render order survives a save unchanged.
"sidebarNav": [
{"id": "images", "pinned": True},
{"id": "video", "pinned": True},
{"id": "hub", "pinned": True},
{"id": "train", "pinned": True},
{"id": "projects", "pinned": False},
{"id": "recipes", "pinned": False},
{"id": "export", "pinned": False},
],
},
},
}

View file

@ -130,6 +130,7 @@ import {
useAppearanceCustomStore,
useSettingsDialogStore,
} from "@/features/settings";
import type { SidebarNavItemId } from "@/features/settings";
import { useEffectiveProfile, UserAvatar } from "@/features/profile";
import { fetchDeviceType, usePlatformStore } from "@/config/env";
import { clearAuthTokens, logout } from "@/features/auth";
@ -204,6 +205,22 @@ const SETTINGS_TAB_MENU_ITEMS: Record<
connections: { icon: CloudIcon, labelKey: "settings.tabs.connections" },
};
// A navigable sidebar row. The same definition renders either as a top-level
// NavItem or as a MoreMenuItem, depending on the user's pin preference.
type NavRowDef = {
icon: typeof ZapIcon;
label: string;
active: boolean;
disabled?: boolean;
tooltip?: string;
spinner?: boolean;
badge?: string;
onClick: () => void;
onIntent?: () => void;
className?: string;
children?: ReactNode;
};
type ConversationExportFormat = "raw-jsonl" | "csv" | "sharegpt-jsonl";
// A pinned project shows this many recent chats before "Show more".
@ -403,6 +420,9 @@ export function AppSidebar() {
const sidebarMenu = useAppearanceCustomStore(
(s) => s.customization.sidebarMenu,
);
const sidebarNav = useAppearanceCustomStore(
(s) => s.customization.sidebarNav,
);
const [usesCustomTitlebar] = useState(shouldUseCustomWindowTitlebar);
const [usesNativeMacTitlebar] = useState(shouldUseNativeMacWindowTitlebar);
// Mac uses Cmd, others use Ctrl. Not Tauri-gated, so it's right on web too.
@ -668,13 +688,150 @@ export function AppSidebar() {
]);
const chatDisabled = trainingInProgress;
// Highlight the "More" row while one of the routes it holds is showing.
const moreSectionActive =
pathname === "/video" ||
pathname.startsWith("/video/") ||
pathname === "/export" ||
pathname.startsWith("/export/") ||
isRecipesRoute;
// One definition per navigable row, so the pinned list and the More flyout
// render the same row from the same source and can't drift apart.
const navRows: Record<SidebarNavItemId, NavRowDef> = {
projects: {
icon: Folder01Icon,
label: t("shell.navigation.projects"),
active: pathname === "/projects" || pathname.startsWith("/projects/"),
onClick: () => {
navigate({ to: "/projects" });
closeMobileIfOpen();
},
onIntent: () => {
preloadSilently(router.preloadRoute({ to: "/projects" }));
},
className: "group/projects-item relative",
// The inline "new project" affordance only makes sense on a real row; in
// the flyout the row is just a link.
children: (
<button
type="button"
aria-label="New project"
onClick={(e) => {
e.stopPropagation();
setProjectCreateMoveTarget(null);
setProjectNameDraft("");
setCreatingProject(true);
}}
className="sidebar-row-action group-hover/projects-item:opacity-100 group-hover/projects-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto group-data-[collapsible=icon]:hidden"
>
<span className="sidebar-row-action-glyph">
<HugeiconsIcon
icon={PlusSignIcon}
strokeWidth={1.75}
className="size-4"
/>
</span>
</button>
),
},
hub: {
icon: DashboardCircleIcon,
label: t("shell.navigation.hub"),
active: pathname === "/hub" || pathname.startsWith("/hub/"),
onClick: () => {
navigate({ to: "/hub" });
closeMobileIfOpen();
},
onIntent: () => {
preloadSilently(router.preloadRoute({ to: "/hub" }));
},
},
images: {
icon: Image03Icon,
label: t("shell.navigation.images"),
badge: t("shell.navigation.newBadge"),
active: pathname === "/images" || pathname.startsWith("/images/"),
onClick: () => {
navigate({ to: "/images" });
closeMobileIfOpen();
},
onIntent: () => {
preloadSilently(router.preloadRoute({ to: "/images" }));
},
},
train: {
icon: TestTubeOutlineIcon,
label: t("shell.navigation.train"),
active: pathname === "/studio" || pathname.startsWith("/studio/"),
disabled: chatOnly,
tooltip: trainDisabledHint,
spinner: trainingInProgress,
onClick: () => {
if (chatOnly) return;
navigate({ to: "/studio" });
closeMobileIfOpen();
},
onIntent: () => {
preloadSilently(router.preloadRoute({ to: "/studio" }));
},
},
// Video is diffusers-only (no native CPU engine), so a chat-only host can
// never load it; disable with a hint instead of bouncing off the root
// guard's redirect.
video: {
icon: FlimSlateIcon,
label: t("shell.navigation.video"),
badge: t("shell.navigation.newBadge"),
active: pathname === "/video" || pathname.startsWith("/video/"),
disabled: chatOnly,
tooltip: chatOnly
? "Video generation needs an NVIDIA or AMD GPU."
: undefined,
onClick: () => {
navigate({ to: "/video" });
closeMobileIfOpen();
},
onIntent: () => {
preloadSilently(router.preloadRoute({ to: "/video" }));
},
},
recipes: {
icon: ChefHatIcon,
label: t("shell.navigation.recipes"),
active: isRecipesRoute,
onClick: () => {
navigate({ to: "/data-recipes" });
closeMobileIfOpen();
},
onIntent: () => {
preloadSilently(router.preloadRoute({ to: "/data-recipes" }));
preloadSilently(
import("@/features/data-recipes").then((module) =>
module.preloadRecipes(),
),
);
},
},
export: {
icon: DownloadSquare01Icon,
label: t("shell.navigation.export"),
active: pathname === "/export" || pathname.startsWith("/export/"),
spinner: exportInProgress,
onClick: () => {
navigate({ to: "/export" });
closeMobileIfOpen();
},
onIntent: () => {
preloadSilently(router.preloadRoute({ to: "/export" }));
preloadSilently(
import("@/features/export/export-navigation-cache").then((module) =>
module.preloadExportData(),
),
);
},
},
};
const pinnedNavIds = sidebarNav
.filter((item) => item.pinned)
.map((item) => item.id);
const overflowNavIds = sidebarNav
.filter((item) => !item.pinned)
.map((item) => item.id);
const showSidebarBrand = !usesCustomTitlebar;
const showCompactMacBrand = showSidebarBrand && usesNativeMacTitlebar;
@ -1452,202 +1609,105 @@ export function AppSidebar() {
<SidebarGroup data-tour="navbar" className="group-data-[collapsible=icon]:px-0 pl-1.5 pr-2 py-0 shrink-0">
<SidebarGroupContent>
<SidebarMenu>
<NavItem
icon={DashboardCircleIcon}
label={t("shell.navigation.hub")}
active={pathname === "/hub" || pathname.startsWith("/hub/")}
onClick={() => {
navigate({ to: "/hub" });
closeMobileIfOpen();
}}
onIntent={() => {
preloadSilently(router.preloadRoute({ to: "/hub" }));
}}
/>
<NavItem
icon={Folder01Icon}
label="Projects"
active={
pathname === "/projects" || pathname.startsWith("/projects/")
}
onClick={() => {
navigate({ to: "/projects" });
closeMobileIfOpen();
}}
onIntent={() => {
preloadSilently(router.preloadRoute({ to: "/projects" }));
}}
className="group/projects-item relative"
>
<button
type="button"
aria-label="New project"
onClick={(e) => {
e.stopPropagation();
setProjectCreateMoveTarget(null);
setProjectNameDraft("");
setCreatingProject(true);
}}
className="sidebar-row-action group-hover/projects-item:opacity-100 group-hover/projects-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto group-data-[collapsible=icon]:hidden"
>
<span className="sidebar-row-action-glyph">
<HugeiconsIcon
icon={PlusSignIcon}
strokeWidth={1.75}
className="size-4"
/>
</span>
</button>
</NavItem>
<NavItem
icon={Image03Icon}
label={t("shell.navigation.images")}
badge={t("shell.navigation.newBadge")}
active={pathname === "/images" || pathname.startsWith("/images/")}
onClick={() => {
navigate({ to: "/images" });
closeMobileIfOpen();
}}
onIntent={() => {
preloadSilently(router.preloadRoute({ to: "/images" }));
}}
/>
<NavItem
icon={TestTubeOutlineIcon}
label={t("shell.navigation.train")}
active={
pathname === "/studio" || pathname.startsWith("/studio/")
}
disabled={chatOnly}
tooltip={trainDisabledHint}
spinner={trainingInProgress}
onClick={() => {
if (chatOnly) return;
navigate({ to: "/studio" });
closeMobileIfOpen();
}}
onIntent={() => {
preloadSilently(router.preloadRoute({ to: "/studio" }));
}}
/>
{/* Rows come from the saved pin order (Settings -> Appearance ->
Sidebar navigation). Pinned ids render here in order; the rest
fall into the More flyout below, so nothing is unreachable. */}
{pinnedNavIds.map((id) => {
const row = navRows[id];
return (
<NavItem
key={id}
icon={row.icon}
label={row.label}
badge={row.badge}
active={row.active}
disabled={row.disabled}
tooltip={row.tooltip}
spinner={row.spinner}
onClick={row.onClick}
onIntent={row.onIntent}
className={row.className}
>
{row.children}
</NavItem>
);
})}
{/* Secondary destinations behind one row, so the primary nav stays short.
Hover or click opens it; the panel flies out to the right. */}
<SidebarMenuItem
onPointerEnter={openMore}
onPointerLeave={closeMoreSoon}
>
<DropdownMenu
open={moreOpen}
onOpenChange={setMoreOpen}
modal={false}
{overflowNavIds.length > 0 && (
<SidebarMenuItem
onPointerEnter={openMore}
onPointerLeave={closeMoreSoon}
>
{/* No `tooltip` prop on the button: with it, SidebarMenuButton
returns a Tooltip root and DropdownMenuTrigger asChild would
hand its ref/handlers to that instead of a DOM node, leaving
the trigger dead. Wrap it here instead, so both triggers
compose onto the same button. */}
<Tooltip>
<TooltipPrimitive.Trigger asChild>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
isActive={moreSectionActive}
className="sidebar-nav-btn h-[33px] rounded-full gap-[8.5px] pl-3 pr-2.5 font-medium group-data-[collapsible=icon]:px-2.5 group-data-[collapsible=icon]:!w-[32px] group-data-[collapsible=icon]:mx-auto"
>
<HugeiconsIcon
icon={MoreHorizontalIcon}
strokeWidth={1.75}
className="size-icon! shrink-0 group-hover/menu-button:animate-icon-pop"
/>
<span className="text-ui-14p5 leading-ui-19 tracking-nav">
{t("shell.navigation.more")}
</span>
</SidebarMenuButton>
</DropdownMenuTrigger>
</TooltipPrimitive.Trigger>
{/* Only the collapsed rail needs it; expanded rows show their label. */}
<TooltipContent
side="right"
align="center"
className="tooltip-compact"
hidden={isMobile || sidebarState !== "collapsed"}
>
{t("shell.navigation.more")}
</TooltipContent>
</Tooltip>
<DropdownMenuContent
side="right"
align="start"
sideOffset={6}
onPointerEnter={openMore}
onPointerLeave={closeMoreSoon}
className="w-48 p-1"
<DropdownMenu
open={moreOpen}
onOpenChange={setMoreOpen}
modal={false}
>
{/* Video is diffusers-only (no native CPU engine), so a chat-only host
can never load it; disable with a hint instead of bouncing off the
root guard's redirect. */}
<MoreMenuItem
icon={FlimSlateIcon}
label={t("shell.navigation.video")}
badge={t("shell.navigation.newBadge")}
active={
pathname === "/video" || pathname.startsWith("/video/")
}
disabled={chatOnly}
tooltip={
chatOnly
? "Video generation needs an NVIDIA or AMD GPU."
: undefined
}
onSelect={() => {
navigate({ to: "/video" });
closeMobileIfOpen();
}}
onIntent={() => {
preloadSilently(router.preloadRoute({ to: "/video" }));
}}
/>
<MoreMenuItem
icon={ChefHatIcon}
label={t("shell.navigation.recipes")}
active={isRecipesRoute}
onSelect={() => {
navigate({ to: "/data-recipes" });
closeMobileIfOpen();
}}
onIntent={() => {
preloadSilently(
router.preloadRoute({ to: "/data-recipes" }),
{/* No `tooltip` prop on the button: with it, SidebarMenuButton
returns a Tooltip root and DropdownMenuTrigger asChild would
hand its ref/handlers to that instead of a DOM node, leaving
the trigger dead. Wrap it here instead, so both triggers
compose onto the same button. */}
<Tooltip>
<TooltipPrimitive.Trigger asChild>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
isActive={overflowNavIds.some(
(id) => navRows[id].active,
)}
className="sidebar-nav-btn h-[33px] rounded-full gap-[8.5px] pl-3 pr-2.5 font-medium group-data-[collapsible=icon]:px-2.5 group-data-[collapsible=icon]:!w-[32px] group-data-[collapsible=icon]:mx-auto"
>
<HugeiconsIcon
icon={MoreHorizontalIcon}
strokeWidth={1.75}
className="size-icon! shrink-0 group-hover/menu-button:animate-icon-pop"
/>
<span className="text-ui-14p5 leading-ui-19 tracking-nav">
{t("shell.navigation.more")}
</span>
</SidebarMenuButton>
</DropdownMenuTrigger>
</TooltipPrimitive.Trigger>
{/* Only the collapsed rail needs it; expanded rows show their label. */}
<TooltipContent
side="right"
align="center"
className="tooltip-compact"
hidden={isMobile || sidebarState !== "collapsed"}
>
{t("shell.navigation.more")}
</TooltipContent>
</Tooltip>
<DropdownMenuContent
side="right"
align="start"
sideOffset={6}
onPointerEnter={openMore}
onPointerLeave={closeMoreSoon}
className="w-48 p-1"
>
{overflowNavIds.map((id) => {
const row = navRows[id];
return (
<MoreMenuItem
key={id}
icon={row.icon}
label={row.label}
badge={row.badge}
active={row.active}
disabled={row.disabled}
tooltip={row.tooltip}
spinner={row.spinner}
onSelect={row.onClick}
onIntent={row.onIntent}
/>
);
preloadSilently(
import("@/features/data-recipes").then((module) =>
module.preloadRecipes(),
),
);
}}
/>
<MoreMenuItem
icon={DownloadSquare01Icon}
label={t("shell.navigation.export")}
active={
pathname === "/export" || pathname.startsWith("/export/")
}
spinner={exportInProgress}
onSelect={() => {
navigate({ to: "/export" });
closeMobileIfOpen();
}}
onIntent={() => {
preloadSilently(router.preloadRoute({ to: "/export" }));
preloadSilently(
import(
"@/features/export/export-navigation-cache"
).then((module) => module.preloadExportData()),
);
}}
/>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
})}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>

View file

@ -0,0 +1,156 @@
// 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 {
ChefHatIcon,
DashboardCircleIcon,
DownloadSquare01Icon,
DragDropVerticalIcon,
Edit03Icon,
FlimSlateIcon,
Folder01Icon,
Image03Icon,
MoreHorizontalIcon,
Search01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Reorder, useDragControls } from "motion/react";
import { Switch } from "@/components/ui/switch";
import { TestTubeOutlineIcon } from "@/lib/hugeicons-derived";
import { useT } from "@/i18n";
import type { TranslationKey } from "@/i18n";
import type { IconSvgElement } from "@hugeicons/react";
import type { SidebarNavItemPref } from "../stores/appearance-custom-store";
import { useAppearanceCustomStore } from "../stores/appearance-custom-store";
const ITEM_META: Record<
SidebarNavItemPref["id"],
{ icon: IconSvgElement; labelKey: TranslationKey }
> = {
projects: { icon: Folder01Icon, labelKey: "shell.navigation.projects" },
hub: { icon: DashboardCircleIcon, labelKey: "shell.navigation.hub" },
images: { icon: Image03Icon, labelKey: "shell.navigation.images" },
train: { icon: TestTubeOutlineIcon, labelKey: "shell.navigation.train" },
video: { icon: FlimSlateIcon, labelKey: "shell.navigation.video" },
recipes: { icon: ChefHatIcon, labelKey: "shell.navigation.recipes" },
export: { icon: DownloadSquare01Icon, labelKey: "shell.navigation.export" },
};
function FixedRow({ icon, label }: { icon: IconSvgElement; label: string }) {
return (
<div className="flex h-9 items-center gap-2.5 rounded-lg px-2 text-muted-foreground/70">
{/* Spacer where the drag handle sits on movable rows. */}
<span className="size-4" aria-hidden="true" />
<HugeiconsIcon icon={icon} strokeWidth={1.75} className="size-4" />
<span className="text-ui-13">{label}</span>
</div>
);
}
function MovableRow({ item }: { item: SidebarNavItemPref }) {
const t = useT();
const controls = useDragControls();
const patch = useAppearanceCustomStore((s) => s.patch);
const sidebarNav = useAppearanceCustomStore((s) => s.customization.sidebarNav);
const meta = ITEM_META[item.id];
return (
<Reorder.Item
value={item.id}
dragListener={false}
dragControls={controls}
layout="position"
// Rows sit flat on the dialog surface; the dragged row lifts above its
// siblings so it stays readable while crossing them.
whileDrag={{
backgroundColor: "var(--popover)",
boxShadow: "0 4px 16px rgb(0 0 0 / 0.18)",
zIndex: 10,
}}
className="relative flex h-9 items-center gap-2.5 rounded-lg px-2"
>
<button
type="button"
aria-label={t("settings.appearance.sidebarNav.dragToReorder")}
onPointerDown={(e) => {
e.preventDefault();
controls.start(e);
}}
className="flex size-4 shrink-0 cursor-grab touch-none items-center justify-center text-muted-foreground active:cursor-grabbing"
>
<HugeiconsIcon
icon={DragDropVerticalIcon}
strokeWidth={1.75}
className="size-4"
/>
</button>
<HugeiconsIcon
icon={meta.icon}
strokeWidth={1.75}
className="size-4 text-foreground/80"
/>
<span className="text-ui-13 text-foreground">{t(meta.labelKey)}</span>
<Switch
className="ml-auto"
aria-label={t("settings.appearance.sidebarNav.pinToSidebar", {
name: t(meta.labelKey),
})}
checked={item.pinned}
onCheckedChange={(pinned) =>
patch({
sidebarNav: sidebarNav.map((entry) =>
entry.id === item.id ? { ...entry, pinned } : entry,
),
})
}
/>
</Reorder.Item>
);
}
/**
* Pin and reorder the sidebar navigation rows. A row with its switch off moves
* into the "More" flyout instead of disappearing, so every page stays reachable.
* New Chat and Search render as static rows: they are actions pinned to the top,
* not destinations.
*/
export function SidebarNavCustomizer() {
const t = useT();
const sidebarNav = useAppearanceCustomStore((s) => s.customization.sidebarNav);
const patch = useAppearanceCustomStore((s) => s.patch);
const unpinnedCount = sidebarNav.filter((item) => !item.pinned).length;
return (
<div className="flex flex-col rounded-xl border border-border/70 p-1.5">
<FixedRow icon={Edit03Icon} label={t("shell.navigation.newChat")} />
<FixedRow icon={Search01Icon} label={t("shell.navigation.search")} />
<Reorder.Group
axis="y"
values={sidebarNav.map((item) => item.id)}
onReorder={(ids: SidebarNavItemPref["id"][]) =>
patch({
sidebarNav: ids.flatMap(
(id) => sidebarNav.find((entry) => entry.id === id) ?? [],
),
})
}
className="flex flex-col"
>
{sidebarNav.map((item) => (
<MovableRow key={item.id} item={item} />
))}
</Reorder.Group>
{/* Only meaningful once something is unpinned -- with everything pinned the
sidebar has no More row at all. */}
{unpinnedCount > 0 && (
<>
<div className="mx-2 my-1 border-t border-border/70" />
<FixedRow
icon={MoreHorizontalIcon}
label={t("settings.appearance.sidebarNav.moreHolds", {
count: String(unpinnedCount),
})}
/>
</>
)}
</div>
);
}

View file

@ -26,6 +26,8 @@ export type {
AppearanceCustomization,
CustomModeColors,
ReduceMotionSetting,
SidebarNavItemId,
SidebarNavItemPref,
} from "./stores/appearance-custom-store";
export { useMonitorOverlayStore } from "./stores/monitor-overlay-store";
export type {

View file

@ -57,6 +57,7 @@ export const SETTINGS_SEARCH_INDEX: Record<SettingsTab, TranslationKey[]> = {
"settings.appearance.custom.codeFontSize.label",
"settings.appearance.custom.fontSmoothing.label",
"settings.appearance.layout.compactSidebar",
"settings.appearance.sidebarNav.title",
"settings.appearance.sidebarMenu.title",
"settings.appearance.sidebarMenu.darkModeToggle",
],

View file

@ -82,6 +82,42 @@ export const SIDEBAR_MENU_DEFAULT_VISIBLE: Record<SidebarMenuItemId, boolean> =
connections: false,
};
/**
* Sidebar NAVIGATION rows the user can pin and reorder (distinct from the
* profile-menu entries above). New Chat and Search stay fixed at the top: they
* are actions, not destinations. Array order is render order; an unpinned row
* moves into the "More" flyout rather than disappearing, so no page becomes
* unreachable.
*/
export const SIDEBAR_NAV_ITEM_IDS = [
"projects",
"hub",
"images",
"train",
"video",
"recipes",
"export",
] as const;
export type SidebarNavItemId = (typeof SIDEBAR_NAV_ITEM_IDS)[number];
export type SidebarNavItemPref = {
id: SidebarNavItemId;
/** true = a top-level sidebar row; false = inside the "More" flyout. */
pinned: boolean;
};
// Matches the shipped layout, so an untouched install looks unchanged.
export const SIDEBAR_NAV_DEFAULT_PINNED: Record<SidebarNavItemId, boolean> = {
projects: true,
hub: true,
images: true,
train: true,
video: false,
recipes: false,
export: false,
};
export const MAX_IMPORTED_FONTS = 3;
/** Imported-font family name cap; must match the backend name max_length (100). */
export const MAX_IMPORTED_FONT_NAME_LENGTH = 100;
@ -113,6 +149,8 @@ export type AppearanceCustomization = {
fontSmoothing: boolean;
/** Order and visibility of the optional sidebar profile menu items. */
sidebarMenu: SidebarMenuItemPref[];
/** Order of the sidebar nav rows, and which are pinned vs. under "More". */
sidebarNav: SidebarNavItemPref[];
};
const EMPTY_MODE_COLORS: CustomModeColors = {
@ -138,6 +176,10 @@ export const DEFAULT_CUSTOMIZATION: AppearanceCustomization = {
id,
visible: SIDEBAR_MENU_DEFAULT_VISIBLE[id],
})),
sidebarNav: SIDEBAR_NAV_ITEM_IDS.map((id) => ({
id,
pinned: SIDEBAR_NAV_DEFAULT_PINNED[id],
})),
};
export const UI_FONT_SIZE_RANGE = { min: 12, max: 20, default: 16 } as const;
@ -222,6 +264,27 @@ function isSidebarMenuItemId(value: unknown): value is SidebarMenuItemId {
return SIDEBAR_MENU_ITEM_IDS.includes(value as SidebarMenuItemId);
}
function isSidebarNavItemId(value: unknown): value is SidebarNavItemId {
return SIDEBAR_NAV_ITEM_IDS.includes(value as SidebarNavItemId);
}
function sanitizeSidebarNav(value: unknown): SidebarNavItemPref[] {
const items: SidebarNavItemPref[] = [];
const seen = new Set<SidebarNavItemId>();
for (const entry of Array.isArray(value) ? value : []) {
const source = (entry ?? {}) as Partial<SidebarNavItemPref>;
if (!isSidebarNavItemId(source.id) || seen.has(source.id)) continue;
seen.add(source.id);
items.push({ id: source.id, pinned: source.pinned !== false });
}
// Ids added after the payload was written land at the end with their default
// pin state, so a new tab shows up where the shipped layout puts it.
for (const id of SIDEBAR_NAV_ITEM_IDS) {
if (!seen.has(id)) items.push({ id, pinned: SIDEBAR_NAV_DEFAULT_PINNED[id] });
}
return items;
}
function sanitizeSidebarMenu(value: unknown): SidebarMenuItemPref[] {
const items: SidebarMenuItemPref[] = [];
const seen = new Set<SidebarMenuItemId>();
@ -273,6 +336,7 @@ export function sanitizeCustomization(value: unknown): AppearanceCustomization {
: "system",
fontSmoothing: source.fontSmoothing !== false,
sidebarMenu: sanitizeSidebarMenu(source.sidebarMenu),
sidebarNav: sanitizeSidebarNav(source.sidebarNav),
};
}

View file

@ -21,6 +21,7 @@ import {
import { PaletteCards } from "../components/palette-cards";
import { SettingsRow } from "../components/settings-row";
import { SidebarMenuCustomizer } from "../components/sidebar-menu-customizer";
import { SidebarNavCustomizer } from "../components/sidebar-nav-customizer";
import {
SettingsGroupDivider,
SettingsSection,
@ -151,6 +152,15 @@ export function AppearanceTab() {
</SettingsRow>
</SettingsSection>
<SettingsSection
title={t("settings.appearance.sidebarNav.title")}
description={t("settings.appearance.sidebarNav.description")}
>
<div className="pt-3">
<SidebarNavCustomizer />
</div>
</SettingsSection>
<SettingsSection
title={t("settings.appearance.sidebarMenu.title")}
description={t("settings.appearance.sidebarMenu.description")}

View file

@ -36,6 +36,7 @@ export const en = {
returnToChat: "Return to Chat",
compare: "Compare",
search: "Search",
projects: "Projects",
hub: "Model hub",
train: "Train",
recipes: "Recipes",
@ -515,6 +516,14 @@ export const en = {
compactSidebarDescription:
"Keep the sidebar expanded instead of collapsing to icons.",
},
sidebarNav: {
title: "Sidebar navigation",
description:
"Pin and reorder the sidebar tabs. Anything unpinned moves into the More menu instead of being hidden. New chat and Search stay fixed.",
dragToReorder: "Drag to reorder",
pinToSidebar: "Pin {name} to the sidebar",
moreHolds: "More ({count})",
},
sidebarMenu: {
title: "Sidebar menu",
description:

View file

@ -44,6 +44,7 @@ export const zhCN = {
recipes: "配方",
images: "图像",
video: "视频",
projects: "项目",
more: "更多",
newBadge: "新",
export: "导出",
@ -369,6 +370,14 @@ export const zhCN = {
compactSidebar: "默认固定侧边栏",
compactSidebarDescription: "保持侧边栏展开,而不是折叠为图标。",
},
sidebarNav: {
title: "侧边栏导航",
description:
"固定并重新排序侧边栏标签。取消固定的项目会移入“更多”菜单而不是被隐藏。新聊天和搜索保持固定。",
dragToReorder: "拖动以重新排序",
pinToSidebar: "将{name}固定到侧边栏",
moreHolds: "更多({count}",
},
sidebarMenu: {
title: "侧边栏菜单",
description: