Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ _In development — bullets added per PR; finalized at release._

### ✨ New Features

- **feat(sidebar): colored menu icons** — sidebar menu icons now render with a per-item accent color: curated colors for known items (`SIDEBAR_ICON_ACCENTS`) plus a deterministic hash-based fallback (`getSidebarIconAccent`) so every item gets a stable, distinct color across sessions. ([#3812](https://github.com/diegosouzapw/OmniRoute/pull/3812) — thanks @rafacpti23)
- **feat(providers): add Factory (factory.ai) as a subscription gateway provider** — `factory` (Factory Droids' hosted gateway) is now a first-class routing provider on the OpenAI-compatible `https://api.factory.ai/v1` endpoint with Bearer apikey auth; the key is supplied from the Dashboard connection (not env). ([#5065](https://github.com/diegosouzapw/OmniRoute/pull/5065) — thanks @KooshaPari)
- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas)
- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic)
Expand Down
19 changes: 17 additions & 2 deletions src/shared/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState, useEffect, useRef, useCallback } from "react";
import { useState, useEffect, useRef, useCallback, type CSSProperties } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/shared/utils/cn";
Expand All @@ -24,6 +24,7 @@ import {
normalizeHiddenSidebarItems,
applySectionOrder,
applyItemOrder,
getSidebarIconAccent,
type SidebarSectionId,
type SidebarItemDefinition,
type SidebarItemGroup,
Expand All @@ -35,6 +36,11 @@ const DEFAULT_EXPANDED: SidebarSectionId = "omni-proxy";
const EXPANDED_SECTIONS_KEY = "sidebar-expanded-sections";
const PINNED_SECTIONS_KEY = "sidebar-pinned-sections";

type SidebarGlyphStyle = CSSProperties & {
"--sidebar-icon-accent": string;
color: string;
};

type SidebarProps = {
onClose?: () => void;
collapsed?: boolean;
Expand Down Expand Up @@ -67,6 +73,13 @@ export default function Sidebar({
onToggleCollapse,
isMacElectron = false,
}: SidebarProps) {
const getIconStyle = (itemId: string): SidebarGlyphStyle => {
const accent = getSidebarIconAccent(itemId);
return {
"--sidebar-icon-accent": accent,
color: accent,
};
};
const pathname = usePathname();
const t = useTranslations("sidebar");
const tc = useTranslations("common");
Expand Down Expand Up @@ -377,7 +390,9 @@ export default function Sidebar({
);
const content = (
<>
<span className={iconClassName}>{item.icon}</span>
<span className={iconClassName} style={getIconStyle(item.id)}>
{item.icon}
</span>
{!collapsed && (
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{item.label}</span>
Expand Down
113 changes: 113 additions & 0 deletions src/shared/constants/sidebarVisibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,119 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [

export type HideableSidebarItemId = (typeof HIDEABLE_SIDEBAR_ITEM_IDS)[number];

export const SIDEBAR_ICON_ACCENTS: Partial<Record<HideableSidebarItemId, string>> = {
home: "#60A5FA",
"api-manager": "#F59E0B",
endpoints: "#38BDF8",
providers: "#818CF8",
combos: "#A855F7",
quota: "#F472B6",
"context-caveman": "#F97316",
"context-rtk": "#2DD4BF",
"context-combos": "#C084FC",
"cli-code": "#FACC15",
"cli-agents": "#93C5FD",
"cloud-agents": "#7DD3FC",
"api-endpoints": "#14B8A6",
webhooks: "#EC4899",
proxy: "#A3E635",
"mitm-proxy": "#FB7185",
"1proxy": "#22D3EE",
analytics: "#06B6D4",
"analytics-combo-health": "#34D399",
"analytics-utilization": "#FBBF24",
costs: "#FB923C",
cache: "#84CC16",
"analytics-compression": "#F97316",
"analytics-search": "#38BDF8",
"analytics-evals": "#A78BFA",
logs: "#CBD5E1",
"logs-proxy": "#A3E635",
"logs-console": "#FACC15",
"logs-activity": "#60A5FA",
health: "#EF4444",
runtime: "#F59E0B",
"costs-pricing": "#FB923C",
"costs-budget": "#22C55E",
"costs-quota-share": "#06B6D4",
audit: "#F43F5E",
"audit-mcp": "#818CF8",
"audit-a2a": "#A855F7",
translator: "#3B82F6",
playground: "#EAB308",
"search-tools": "#0891B2",
memory: "#10B981",
skills: "#F43F5E",
"agent-skills": "#D946EF",
mcp: "#8B5CF6",
a2a: "#06B6D4",
leaderboard: "#FACC15",
profile: "#60A5FA",
tokens: "#A3E635",
media: "#D946EF",
batch: "#14B8A6",
"batch-files": "#38BDF8",
settings: "#94A3B8",
"settings-general": "#64748B",
"settings-appearance": "#D946EF",
"settings-ai": "#A78BFA",
"settings-routing": "#06B6D4",
"settings-resilience": "#22C55E",
"settings-advanced": "#F97316",
"settings-security": "#EF4444",
"settings-feature-flags": "#FACC15",
"settings-sidebar": "#38BDF8",
docs: "#2563EB",
issues: "#DC2626",
changelog: "#F59E0B",
};

export const SIDEBAR_SUBITEM_ICON_ACCENTS: Record<string, string> = {};

function getDeterministicIconAccent(id: string): string {
let hash = 0;
for (let index = 0; index < id.length; index += 1) {
hash = (hash * 31 + id.charCodeAt(index)) >>> 0;
}
const hue = hash % 360;
const saturation = 72;
const lightness = 56;
const chroma = (1 - Math.abs((2 * lightness) / 100 - 1)) * (saturation / 100);
const huePrime = hue / 60;
const x = chroma * (1 - Math.abs((huePrime % 2) - 1));
const match = lightness / 100 - chroma / 2;
const [red, green, blue] =
huePrime < 1
? [chroma, x, 0]
: huePrime < 2
? [x, chroma, 0]
: huePrime < 3
? [0, chroma, x]
: huePrime < 4
? [0, x, chroma]
: huePrime < 5
? [x, 0, chroma]
: [chroma, 0, x];

return [red, green, blue]
.map((channel) =>
Math.round((channel + match) * 255)
.toString(16)
.padStart(2, "0")
.toUpperCase()
)
.join("")
.replace(/^/, "#");
}

export function getSidebarIconAccent(id: string): string {
return (
SIDEBAR_ICON_ACCENTS[id as HideableSidebarItemId] ||
SIDEBAR_SUBITEM_ICON_ACCENTS[id] ||
getDeterministicIconAccent(id)
);
}

export type SidebarSectionId =
| "home"
| "omni-proxy"
Expand Down
37 changes: 37 additions & 0 deletions tests/unit/sidebar-icon-accents-3812.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import test from "node:test";
import assert from "node:assert/strict";

import {
SIDEBAR_ICON_ACCENTS,
getSidebarIconAccent,
} from "../../src/shared/constants/sidebarVisibility.ts";

/**
* PR #3812 — colored sidebar menu icons.
*
* getSidebarIconAccent(id) returns the curated accent for known menu items and a
* deterministic generated accent for everything else, so every item gets a
* stable color across renders/sessions.
*/

test("#3812 returns the curated accent for a mapped sidebar item", () => {
assert.equal(getSidebarIconAccent("home"), SIDEBAR_ICON_ACCENTS.home);
assert.equal(getSidebarIconAccent("settings"), SIDEBAR_ICON_ACCENTS.settings);
});

test("#3812 falls back to a valid hex accent for an unmapped id", () => {
const accent = getSidebarIconAccent("some-unknown-item-xyz");
assert.match(accent, /^#[0-9A-F]{6}$/, `expected a #RRGGBB hex, got ${accent}`);
});

test("#3812 the fallback accent is deterministic (same id → same color)", () => {
const a = getSidebarIconAccent("deterministic-check-id");
const b = getSidebarIconAccent("deterministic-check-id");
assert.equal(a, b, "the generated accent must be stable for the same id");
});

test("#3812 different unmapped ids generally produce different accents", () => {
const first = getSidebarIconAccent("alpha-item-id");
const second = getSidebarIconAccent("beta-item-id");
assert.notEqual(first, second, "distinct ids should hash to distinct accents here");
});
Loading