+
+
{/* Icon */}
layers
@@ -1222,17 +1341,20 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
agentContextCache: boolean;
};
- const getEmptyCreateDraftSnapshot = (): CreateDraftSnapshot => ({
- name: "",
- models: [],
- strategy: "priority",
- config: {},
- showAdvanced: false,
- nameError: "",
- agentSystemMessage: "",
- agentToolFilter: "",
- agentContextCache: false,
- });
+ const getEmptyCreateDraftSnapshot = useCallback(
+ (): CreateDraftSnapshot => ({
+ name: "",
+ models: [],
+ strategy: "priority",
+ config: {},
+ showAdvanced: false,
+ nameError: "",
+ agentSystemMessage: "",
+ agentToolFilter: "",
+ agentContextCache: false,
+ }),
+ []
+ );
const t = useTranslations("combos");
const tc = useTranslations("common");
@@ -1486,7 +1608,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
return () => {
cancelled = true;
};
- }, [combo, isOpen, resetFormForCombo]);
+ }, [combo, getEmptyCreateDraftSnapshot, isOpen, resetFormForCombo]);
useEffect(() => {
if (!strategyChangeMountedRef.current) {
diff --git a/src/app/api/combos/reorder/route.ts b/src/app/api/combos/reorder/route.ts
new file mode 100644
index 0000000000..174c87e5cd
--- /dev/null
+++ b/src/app/api/combos/reorder/route.ts
@@ -0,0 +1,51 @@
+import { NextResponse } from "next/server";
+import { reorderCombos, isCloudEnabled } from "@/lib/localDb";
+import { getConsistentMachineId } from "@/shared/utils/machineId";
+import { syncToCloud } from "@/lib/cloudSync";
+import { reorderCombosSchema } from "@/shared/validation/schemas";
+import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
+
+// POST /api/combos/reorder - Persist combo ordering
+export async function POST(request) {
+ let rawBody;
+ try {
+ rawBody = await request.json();
+ } catch {
+ return NextResponse.json(
+ {
+ error: {
+ message: "Invalid request",
+ details: [{ field: "body", message: "Invalid JSON body" }],
+ },
+ },
+ { status: 400 }
+ );
+ }
+
+ try {
+ const validation = validateBody(reorderCombosSchema, rawBody);
+ if (isValidationFailure(validation)) {
+ return NextResponse.json({ error: validation.error }, { status: 400 });
+ }
+
+ const combos = await reorderCombos(validation.data.comboIds);
+ await syncToCloudIfEnabled();
+
+ return NextResponse.json({ combos });
+ } catch (error) {
+ console.log("Error reordering combos:", error);
+ return NextResponse.json({ error: "Failed to reorder combos" }, { status: 500 });
+ }
+}
+
+async function syncToCloudIfEnabled() {
+ try {
+ const cloudEnabled = await isCloudEnabled();
+ if (!cloudEnabled) return;
+
+ const machineId = await getConsistentMachineId();
+ await syncToCloud(machineId);
+ } catch (error) {
+ console.log("Error syncing to cloud:", error);
+ }
+}
diff --git a/src/lib/db/combos.ts b/src/lib/db/combos.ts
index d52832e284..05494bfc8b 100644
--- a/src/lib/db/combos.ts
+++ b/src/lib/db/combos.ts
@@ -17,33 +17,57 @@ function getSerializedData(value: unknown): string | null {
return typeof row.data === "string" ? row.data : null;
}
+function getSortOrder(value: unknown): number | null {
+ const row = asRecord(value);
+ return typeof row.sort_order === "number" ? row.sort_order : null;
+}
+
+function withSortOrder(payload: string, sortOrder: number | null): JsonRecord {
+ const parsed = JSON.parse(payload) as JsonRecord;
+ if (typeof sortOrder === "number") {
+ parsed.sortOrder = sortOrder;
+ }
+ return parsed;
+}
+
+function getNextSortOrder() {
+ const db = getDbInstance();
+ const row = db.prepare("SELECT COALESCE(MAX(sort_order), 0) AS sort_order FROM combos").get();
+ const sortOrder = getSortOrder(row);
+ return (sortOrder ?? 0) + 1;
+}
+
export async function getCombos() {
const db = getDbInstance();
return db
- .prepare("SELECT data FROM combos ORDER BY name")
+ .prepare("SELECT data, sort_order FROM combos ORDER BY sort_order ASC, name COLLATE NOCASE ASC")
.all()
- .map((row) => getSerializedData(row))
- .filter((row): row is string => row !== null)
- .map((row) => JSON.parse(row));
+ .map((row) => {
+ const payload = getSerializedData(row);
+ if (!payload) return null;
+ return withSortOrder(payload, getSortOrder(row));
+ })
+ .filter((row): row is JsonRecord => row !== null);
}
export async function getComboById(id: string) {
const db = getDbInstance();
- const row = db.prepare("SELECT data FROM combos WHERE id = ?").get(id);
+ const row = db.prepare("SELECT data, sort_order FROM combos WHERE id = ?").get(id);
const payload = getSerializedData(row);
- return payload ? JSON.parse(payload) : null;
+ return payload ? withSortOrder(payload, getSortOrder(row)) : null;
}
export async function getComboByName(name: string) {
const db = getDbInstance();
- const row = db.prepare("SELECT data FROM combos WHERE name = ?").get(name);
+ const row = db.prepare("SELECT data, sort_order FROM combos WHERE name = ?").get(name);
const payload = getSerializedData(row);
- return payload ? JSON.parse(payload) : null;
+ return payload ? withSortOrder(payload, getSortOrder(row)) : null;
}
export async function createCombo(data: JsonRecord) {
const db = getDbInstance();
const now = new Date().toISOString();
+ const sortOrder = typeof data.sortOrder === "number" ? data.sortOrder : getNextSortOrder();
const combo = {
id: uuidv4(),
@@ -52,13 +76,14 @@ export async function createCombo(data: JsonRecord) {
strategy: data.strategy || "priority",
config: data.config || {},
isHidden: Boolean(data.isHidden),
+ sortOrder,
createdAt: now,
updatedAt: now,
};
db.prepare(
- "INSERT INTO combos (id, name, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?)"
- ).run(combo.id, combo.name, JSON.stringify(combo), now, now);
+ "INSERT INTO combos (id, name, data, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
+ ).run(combo.id, combo.name, JSON.stringify(combo), sortOrder, now, now);
backupDbFile("pre-write");
return combo;
@@ -66,23 +91,99 @@ export async function createCombo(data: JsonRecord) {
export async function updateCombo(id: string, data: JsonRecord) {
const db = getDbInstance();
- const existing = db.prepare("SELECT data FROM combos WHERE id = ?").get(id);
+ const existing = db.prepare("SELECT data, sort_order FROM combos WHERE id = ?").get(id);
if (!existing) return null;
const serializedCurrent = getSerializedData(existing);
if (!serializedCurrent) return null;
- const current = JSON.parse(serializedCurrent);
- const merged = { ...current, ...data, updatedAt: new Date().toISOString() };
+ const current = withSortOrder(serializedCurrent, getSortOrder(existing));
+ const sortOrder =
+ typeof data.sortOrder === "number"
+ ? data.sortOrder
+ : typeof current.sortOrder === "number"
+ ? current.sortOrder
+ : getNextSortOrder();
+ const merged: JsonRecord = {
+ ...current,
+ ...data,
+ sortOrder,
+ updatedAt: new Date().toISOString(),
+ };
+ const currentName = typeof current.name === "string" ? current.name : "";
+ const nextName =
+ typeof merged["name"] === "string" && merged["name"].trim().length > 0
+ ? merged["name"]
+ : currentName;
- db.prepare("UPDATE combos SET name = ?, data = ?, updated_at = ? WHERE id = ?").run(
- merged.name,
- JSON.stringify(merged),
- merged.updatedAt,
- id
- );
+ db.prepare(
+ "UPDATE combos SET name = ?, data = ?, sort_order = ?, updated_at = ? WHERE id = ?"
+ ).run(nextName, JSON.stringify({ ...merged, name: nextName }), sortOrder, merged.updatedAt, id);
backupDbFile("pre-write");
- return merged;
+ return { ...merged, name: nextName };
+}
+
+export async function reorderCombos(comboIds: string[]) {
+ const db = getDbInstance();
+ const rows = db
+ .prepare(
+ "SELECT id, name, data, sort_order FROM combos ORDER BY sort_order ASC, name COLLATE NOCASE ASC"
+ )
+ .all();
+ if (rows.length === 0) return [];
+
+ const existingIds = new Set(
+ rows
+ .map((row) => {
+ const record = asRecord(row);
+ return typeof record.id === "string" ? record.id : null;
+ })
+ .filter((id): id is string => id !== null)
+ );
+
+ const seen = new Set();
+ const requestedIds = comboIds.filter((id) => {
+ if (!existingIds.has(id) || seen.has(id)) return false;
+ seen.add(id);
+ return true;
+ });
+
+ const orderedIds = [
+ ...requestedIds,
+ ...rows
+ .map((row) => {
+ const record = asRecord(row);
+ return typeof record.id === "string" ? record.id : null;
+ })
+ .filter((id): id is string => id !== null && !seen.has(id)),
+ ];
+
+ const update = db.prepare(
+ "UPDATE combos SET data = ?, sort_order = ?, updated_at = ? WHERE id = ?"
+ );
+ const now = new Date().toISOString();
+ const rowById = new Map(
+ rows.map((row) => {
+ const record = asRecord(row);
+ return [String(record.id), row];
+ })
+ );
+
+ const reorderTransaction = db.transaction(() => {
+ orderedIds.forEach((id, index) => {
+ const row = rowById.get(id);
+ const payload = row ? getSerializedData(row) : null;
+ if (!payload) return;
+ const combo = withSortOrder(payload, getSortOrder(row));
+ const sortOrder = index + 1;
+ const updatedCombo = { ...combo, sortOrder, updatedAt: now };
+ update.run(JSON.stringify(updatedCombo), sortOrder, now, id);
+ });
+ });
+
+ reorderTransaction();
+ backupDbFile("pre-write");
+ return getCombos();
}
export async function deleteCombo(id: string) {
diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts
index 90e66d4420..6512505037 100644
--- a/src/lib/db/core.ts
+++ b/src/lib/db/core.ts
@@ -111,6 +111,7 @@ const SCHEMA_SQL = `
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
data TEXT NOT NULL,
+ sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@@ -406,6 +407,11 @@ function ensureCallLogsColumns(db: SqliteDatabase) {
}
}
+function hasColumn(db: SqliteDatabase, tableName: string, columnName: string): boolean {
+ const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name?: string }>;
+ return rows.some((row) => row.name === columnName);
+}
+
export function getDbInstance(): SqliteDatabase {
const existing = getDb();
if (existing) return existing;
@@ -512,6 +518,12 @@ export function getDbInstance(): SqliteDatabase {
INSERT OR IGNORE INTO _omniroute_migrations (version, name)
VALUES ('001', 'initial_schema');
`);
+ if (hasColumn(db, "combos", "sort_order")) {
+ db.prepare("INSERT OR IGNORE INTO _omniroute_migrations (version, name) VALUES (?, ?)").run(
+ "020",
+ "combo_sort_order"
+ );
+ }
runMigrations(db);
// Auto-migrate from db.json if exists
@@ -702,16 +714,21 @@ function migrateFromJson(db: SqliteDatabase, jsonPath: string) {
// 4. Combos
const insertCombo = db.prepare(`
- INSERT OR REPLACE INTO combos (id, name, data, created_at, updated_at)
- VALUES (@id, @name, @data, @createdAt, @updatedAt)
+ INSERT OR REPLACE INTO combos (id, name, data, sort_order, created_at, updated_at)
+ VALUES (@id, @name, @data, @sortOrder, @createdAt, @updatedAt)
`);
- for (const combo of data.combos || []) {
+ for (const [index, combo] of (data.combos || []).entries()) {
+ const normalizedCombo = {
+ ...combo,
+ sortOrder: typeof combo.sortOrder === "number" ? combo.sortOrder : index + 1,
+ };
insertCombo.run({
- id: combo.id,
- name: combo.name,
- data: JSON.stringify(combo),
- createdAt: combo.createdAt || new Date().toISOString(),
- updatedAt: combo.updatedAt || new Date().toISOString(),
+ id: normalizedCombo.id,
+ name: normalizedCombo.name,
+ data: JSON.stringify(normalizedCombo),
+ sortOrder: normalizedCombo.sortOrder,
+ createdAt: normalizedCombo.createdAt || new Date().toISOString(),
+ updatedAt: normalizedCombo.updatedAt || new Date().toISOString(),
});
}
diff --git a/src/lib/db/jsonMigration.ts b/src/lib/db/jsonMigration.ts
index 0ab294a561..b3128cdd9c 100644
--- a/src/lib/db/jsonMigration.ts
+++ b/src/lib/db/jsonMigration.ts
@@ -75,8 +75,8 @@ export function runJsonMigration(
);
const insertCombo = db.prepare(`
- INSERT OR REPLACE INTO combos (id, name, data, created_at, updated_at)
- VALUES (@id, @name, @data, @createdAt, @updatedAt)
+ INSERT OR REPLACE INTO combos (id, name, data, sort_order, created_at, updated_at)
+ VALUES (@id, @name, @data, @sortOrder, @createdAt, @updatedAt)
`);
const insertKey = db.prepare(`
@@ -171,13 +171,18 @@ export function runJsonMigration(
}
// 5. Combos
- for (const combo of data.combos ?? []) {
+ for (const [index, combo] of (data.combos ?? []).entries()) {
+ const normalizedCombo = {
+ ...combo,
+ sortOrder: typeof combo.sortOrder === "number" ? combo.sortOrder : index + 1,
+ };
insertCombo.run({
- id: combo.id,
- name: combo.name,
- data: JSON.stringify(combo),
- createdAt: combo.createdAt ?? new Date().toISOString(),
- updatedAt: combo.updatedAt ?? new Date().toISOString(),
+ id: normalizedCombo.id,
+ name: normalizedCombo.name,
+ data: JSON.stringify(normalizedCombo),
+ sortOrder: normalizedCombo.sortOrder,
+ createdAt: normalizedCombo.createdAt ?? new Date().toISOString(),
+ updatedAt: normalizedCombo.updatedAt ?? new Date().toISOString(),
});
}
diff --git a/src/lib/db/migrations/020_combo_sort_order.sql b/src/lib/db/migrations/020_combo_sort_order.sql
new file mode 100644
index 0000000000..4f07e4781d
--- /dev/null
+++ b/src/lib/db/migrations/020_combo_sort_order.sql
@@ -0,0 +1,16 @@
+ALTER TABLE combos ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
+
+WITH ordered_combos AS (
+ SELECT
+ id,
+ ROW_NUMBER() OVER (
+ ORDER BY created_at ASC, updated_at ASC, name COLLATE NOCASE ASC
+ ) AS next_sort_order
+ FROM combos
+)
+UPDATE combos
+SET sort_order = (
+ SELECT next_sort_order
+ FROM ordered_combos
+ WHERE ordered_combos.id = combos.id
+);
diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts
index 3f10285bed..d85ac54ae7 100755
--- a/src/lib/localDb.ts
+++ b/src/lib/localDb.ts
@@ -75,6 +75,7 @@ export {
getComboByName,
createCombo,
updateCombo,
+ reorderCombos,
deleteCombo,
} from "./db/combos";
diff --git a/src/shared/constants/sidebarVisibility.ts b/src/shared/constants/sidebarVisibility.ts
index b6e68bef2d..ebf27f05ec 100644
--- a/src/shared/constants/sidebarVisibility.ts
+++ b/src/shared/constants/sidebarVisibility.ts
@@ -7,8 +7,8 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
"auto-combo",
"costs",
"analytics",
- "limits",
"cache",
+ "limits",
"cli-tools",
"agents",
"memory",
@@ -17,8 +17,8 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
"playground",
"media",
"search-tools",
- "health",
"logs",
+ "health",
"audit",
"settings",
"docs",
@@ -55,8 +55,8 @@ const PRIMARY_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [
{ id: "auto-combo", href: "/dashboard/auto-combo", i18nKey: "autoCombo", icon: "auto_awesome" },
{ id: "costs", href: "/dashboard/costs", i18nKey: "costs", icon: "account_balance_wallet" },
{ id: "analytics", href: "/dashboard/analytics", i18nKey: "analytics", icon: "analytics" },
- { id: "limits", href: "/dashboard/limits", i18nKey: "limits", icon: "tune" },
{ id: "cache", href: "/dashboard/cache", i18nKey: "cache", icon: "cached" },
+ { id: "limits", href: "/dashboard/limits", i18nKey: "limits", icon: "tune" },
{ id: "media", href: "/dashboard/cache/media", i18nKey: "media", icon: "perm_media" },
];
@@ -79,8 +79,8 @@ const DEBUG_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [
];
const SYSTEM_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [
- { id: "health", href: "/dashboard/health", i18nKey: "health", icon: "health_and_safety" },
{ id: "logs", href: "/dashboard/logs", i18nKey: "logs", icon: "description" },
+ { id: "health", href: "/dashboard/health", i18nKey: "health", icon: "health_and_safety" },
{ id: "audit", href: "/dashboard/audit", i18nKey: "auditLog", icon: "history" },
{ id: "settings", href: "/dashboard/settings", i18nKey: "settings", icon: "settings" },
];
diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts
index 4b9b129d67..89391e293c 100644
--- a/src/shared/validation/schemas.ts
+++ b/src/shared/validation/schemas.ts
@@ -925,6 +925,20 @@ export const updateComboSchema = z
}
});
+export const reorderCombosSchema = z
+ .object({
+ comboIds: z.array(z.string().trim().min(1).max(200)).min(1).max(1000),
+ })
+ .superRefine((value, ctx) => {
+ if (new Set(value.comboIds).size !== value.comboIds.length) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "comboIds must be unique",
+ path: ["comboIds"],
+ });
+ }
+ });
+
export const testComboSchema = z.object({
comboName: z.string().trim().min(1, "comboName is required"),
});
diff --git a/tests/e2e/combos-flow.spec.ts b/tests/e2e/combos-flow.spec.ts
index dd8382c178..92a8c26c04 100644
--- a/tests/e2e/combos-flow.spec.ts
+++ b/tests/e2e/combos-flow.spec.ts
@@ -7,6 +7,7 @@ type ComboStub = {
models: unknown[];
config: Record;
isActive: boolean;
+ sortOrder?: number;
};
type ComboCreatePayload = {
@@ -210,4 +211,134 @@ test.describe("Combos flow", () => {
const testResultsModal = page.getByRole("dialog").last();
await expect(testResultsModal).toContainText(/qa-test-model/i);
});
+
+ test("allows dragging combo cards to persist manual order", async ({ page }) => {
+ const state: {
+ combos: ComboStub[];
+ reorderRequests: number;
+ } = {
+ combos: [
+ {
+ id: "combo-1",
+ name: "alpha-combo",
+ strategy: "priority",
+ models: ["openai/alpha"],
+ config: {},
+ isActive: true,
+ sortOrder: 1,
+ },
+ {
+ id: "combo-2",
+ name: "bravo-combo",
+ strategy: "priority",
+ models: ["openai/bravo"],
+ config: {},
+ isActive: true,
+ sortOrder: 2,
+ },
+ {
+ id: "combo-3",
+ name: "charlie-combo",
+ strategy: "priority",
+ models: ["openai/charlie"],
+ config: {},
+ isActive: true,
+ sortOrder: 3,
+ },
+ ],
+ reorderRequests: 0,
+ };
+
+ await page.route("**/api/combos/metrics", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ metrics: {} }),
+ });
+ });
+
+ await page.route("**/api/settings/proxy", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ combos: {} }),
+ });
+ });
+
+ await page.route("**/api/providers", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ connections: [] }),
+ });
+ });
+
+ await page.route("**/api/provider-nodes", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ nodes: [] }),
+ });
+ });
+
+ await page.route("**/api/combos/reorder", async (route) => {
+ state.reorderRequests += 1;
+ const payload = route.request().postDataJSON() as { comboIds?: string[] };
+ const nextIds = Array.isArray(payload?.comboIds) ? payload.comboIds : [];
+ const comboById = new Map(state.combos.map((combo) => [combo.id, combo]));
+ state.combos = nextIds
+ .map((id, index) => {
+ const combo = comboById.get(id);
+ return combo ? { ...combo, sortOrder: index + 1 } : null;
+ })
+ .filter((combo): combo is ComboStub => combo !== null);
+
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ combos: state.combos }),
+ });
+ });
+
+ await page.route("**/api/combos", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ combos: state.combos }),
+ });
+ });
+
+ await page.goto("/dashboard/combos");
+ await page.waitForLoadState("networkidle");
+
+ const redirectedToLogin = page.url().includes("/login");
+ test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
+
+ const comboCards = page.locator('[data-testid^="combo-card-"]');
+ await expect
+ .poll(async () =>
+ comboCards.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")))
+ )
+ .toEqual(["combo-card-combo-1", "combo-card-combo-2", "combo-card-combo-3"]);
+
+ await page
+ .getByTestId("combo-drag-handle-combo-3")
+ .dragTo(page.getByTestId("combo-card-combo-1"));
+
+ await expect.poll(() => state.reorderRequests).toBe(1);
+ await expect
+ .poll(async () =>
+ comboCards.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")))
+ )
+ .toEqual(["combo-card-combo-3", "combo-card-combo-1", "combo-card-combo-2"]);
+
+ await page.reload();
+ await page.waitForLoadState("networkidle");
+
+ await expect
+ .poll(async () =>
+ comboCards.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")))
+ )
+ .toEqual(["combo-card-combo-3", "combo-card-combo-1", "combo-card-combo-2"]);
+ });
});
diff --git a/tests/unit/db-combos-crud.test.mjs b/tests/unit/db-combos-crud.test.mjs
index 8989210374..c97f9eb714 100644
--- a/tests/unit/db-combos-crud.test.mjs
+++ b/tests/unit/db-combos-crud.test.mjs
@@ -47,11 +47,12 @@ test("createCombo stores default strategy and supports lookup by id and name", a
});
assert.equal(combo.strategy, "priority");
+ assert.equal(combo.sortOrder, 1);
assert.deepEqual(await combosDb.getComboById(combo.id), combo);
assert.deepEqual(await combosDb.getComboByName("Priority Combo"), combo);
});
-test("getCombos returns parsed combos sorted by name", async () => {
+test("getCombos returns parsed combos in persisted sort order", async () => {
await combosDb.createCombo({
name: "Zulu",
models: [{ provider: "openai", model: "gpt-4.1" }],
@@ -65,7 +66,11 @@ test("getCombos returns parsed combos sorted by name", async () => {
assert.deepEqual(
combos.map((combo) => combo.name),
- ["Alpha", "Zulu"]
+ ["Zulu", "Alpha"]
+ );
+ assert.deepEqual(
+ combos.map((combo) => combo.sortOrder),
+ [1, 2]
);
});
@@ -91,6 +96,35 @@ test("updateCombo merges fields while preserving immutable data", async () => {
assert.deepEqual(await combosDb.getComboById(combo.id), updated);
});
+test("reorderCombos persists manual combo ordering in sqlite", async () => {
+ const alpha = await combosDb.createCombo({
+ name: "Alpha",
+ models: [{ provider: "openai", model: "gpt-4.1" }],
+ });
+ const bravo = await combosDb.createCombo({
+ name: "Bravo",
+ models: [{ provider: "anthropic", model: "claude-3-7-sonnet" }],
+ });
+ const charlie = await combosDb.createCombo({
+ name: "Charlie",
+ models: [{ provider: "google", model: "gemini-2.5-pro" }],
+ });
+
+ const reordered = await combosDb.reorderCombos([charlie.id, alpha.id, bravo.id]);
+
+ assert.deepEqual(
+ reordered.map((combo) => combo.name),
+ ["Charlie", "Alpha", "Bravo"]
+ );
+ assert.deepEqual(
+ reordered.map((combo) => combo.sortOrder),
+ [1, 2, 3]
+ );
+ assert.equal((await combosDb.getComboById(charlie.id))?.sortOrder, 1);
+ assert.equal((await combosDb.getComboById(alpha.id))?.sortOrder, 2);
+ assert.equal((await combosDb.getComboById(bravo.id))?.sortOrder, 3);
+});
+
test("deleteCombo reports missing ids and removes existing rows", async () => {
const combo = await combosDb.createCombo({
name: "Delete Me",
diff --git a/tests/unit/sidebar-visibility.test.mjs b/tests/unit/sidebar-visibility.test.mjs
new file mode 100644
index 0000000000..78b52d9de8
--- /dev/null
+++ b/tests/unit/sidebar-visibility.test.mjs
@@ -0,0 +1,40 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+const sidebarVisibility = await import("../../src/shared/constants/sidebarVisibility.ts");
+
+test("system sidebar items place logs before health", () => {
+ const systemSection = sidebarVisibility.SIDEBAR_SECTIONS.find(
+ (section) => section.id === "system"
+ );
+
+ assert.ok(systemSection, "expected system sidebar section to exist");
+ assert.deepEqual(
+ systemSection.items.map((item) => item.id),
+ ["logs", "health", "audit", "settings"]
+ );
+});
+
+test("primary sidebar items place limits after cache", () => {
+ const primarySection = sidebarVisibility.SIDEBAR_SECTIONS.find(
+ (section) => section.id === "primary"
+ );
+
+ assert.ok(primarySection, "expected primary sidebar section to exist");
+ assert.deepEqual(
+ primarySection.items.map((item) => item.id),
+ [
+ "home",
+ "endpoints",
+ "api-manager",
+ "providers",
+ "combos",
+ "auto-combo",
+ "costs",
+ "analytics",
+ "cache",
+ "limits",
+ "media",
+ ]
+ );
+});