fix(dashboard): mobile-first layout for the dashboard and shared responsive primitives (#15532)

Merged via /merge-batch (2026-10-18). Maintainer merge of the current base on top: resolved ConnectionRow / ConnectionsHeaderToolbar / CustomModelsSection keeping every later base feature (ConnectionIdChip, CodexPingNowButton, proxy badge button, Antigravity verify link, Codex vertical layout #16248, the music endpoint) under the new responsive layout; moved ModelEndpointTagsEditor (#16956) to its own line in ModelRow; aligned two layout assertions to the new action-bar wrapper (no assertion weakened). typecheck core, ESLint on all changed files, complexity/cognitive/file-size, i18n key coverage, and the PR + touching UI tests green (load-induced timeouts re-passed in isolation). Remaining CI reds are the inherited base-red #15306. Thanks @HDBR!
This commit is contained in:
Hildelbrando Lins 2026-10-18 20:23:20 -03:00 • committed by GitHub
parent 0a2753c551
commit 40523566e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1023 additions and 488 deletions

View file

@ -0,0 +1 @@
- Fix the dashboard on phones: Material Symbols icons now honour their Tailwind size classes (the icon font's unlayered `font-size: 24px` overrode every utility), provider screens, filters, toolbars and model cards fit and fill the viewport, page/section titles stay on one line with header actions below, toggle thumbs are centred and spinners rotate around their own centre. Adds shared responsive primitives (`ActionBar`, `FilterPills`, `ReorderControl`, `ProviderCardGrid`, `AutoGrid`, `CapabilityBadge`, compact `Select`).

View file

@ -795,7 +795,7 @@
},
"src/app/(dashboard)/dashboard/providers/page.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 4
"count": 2
}
},
"src/app/(dashboard)/dashboard/providers/utils/buildCurl.ts": {

View file

@ -247,7 +247,10 @@ export default function FaroChat() {
onTouchStart={() => void startRecording()}
onTouchEnd={stopRecording}
>
🎙 {voiceLabel[voice]}
<span className="material-symbols-outlined text-[14px] leading-none" aria-hidden="true">
mic
</span>{" "}
{voiceLabel[voice]}
</button>
<label className="flex items-center gap-1 text-xs text-text-muted">
<input type="checkbox" checked={speak} onChange={(e) => setSpeak(e.target.checked)} />

View file

@ -9,7 +9,7 @@ import ConnectionTestButton from "@/shared/components/ConnectionTestButton";
import { readCookieExpiresAt } from "@/shared/utils/webCookieExpiry";
import { useState, useEffect } from "react";
import { useLocale, useTranslations } from "next-intl";
import { Badge, Button, Toggle } from "@/shared/components";
import { Badge, Button, ReorderControl, Toggle } from "@/shared/components";
import { pickDisplayValue } from "@/shared/utils/maskEmail";
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
import { isClaudeExtraUsageBlockEnabled } from "@/lib/providers/claudeExtraUsage";
@ -320,9 +320,14 @@ export default function ConnectionRow({
return (
<div
className={`group flex ${isCodex ? "flex-col items-stretch gap-3" : "items-center justify-between"} p-3 rounded-lg hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors ${connection.isActive === false ? "opacity-60" : ""}`}
className={`group flex ${isCodex ? "flex-col items-stretch gap-3" : "flex-wrap items-center justify-between gap-x-3 gap-y-2"} p-3 rounded-lg hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors ${connection.isActive === false ? "opacity-60" : ""}`}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
{/* basis-64 lets the action bar drop to its own line on narrow screens instead of
pushing the row past the viewport (the page clips x-overflow, so buttons vanished).
Codex rows stack vertically (#16248), where a 16rem basis would become a height. */}
<div
className={`flex items-center gap-2 sm:gap-3 flex-1 min-w-0 ${isCodex ? "" : "basis-64"}`}
>
{onToggleSelect && (
<input
type="checkbox"
@ -331,28 +336,17 @@ export default function ConnectionRow({
className="w-4 h-4 shrink-0 rounded border-border text-primary focus:ring-primary/30 cursor-pointer"
/>
)}
{/* Priority arrows */}
<div className="flex flex-col">
<button
onClick={onMoveUp}
disabled={isFirst}
className={`p-0.5 rounded ${isFirst ? "text-text-muted/30 cursor-not-allowed" : "hover:bg-sidebar text-text-muted hover:text-primary"}`}
>
<span className="material-symbols-outlined text-sm">keyboard_arrow_up</span>
</button>
<button
onClick={onMoveDown}
disabled={isLast}
className={`p-0.5 rounded ${isLast ? "text-text-muted/30 cursor-not-allowed" : "hover:bg-sidebar text-text-muted hover:text-primary"}`}
>
<span className="material-symbols-outlined text-sm">keyboard_arrow_down</span>
</button>
</div>
<span className="material-symbols-outlined text-base text-text-muted">
<ReorderControl
onMoveUp={onMoveUp}
onMoveDown={onMoveDown}
isFirst={isFirst}
isLast={isLast}
/>
<span className="material-symbols-outlined hidden text-base text-text-muted sm:inline-block">
{isOAuth ? "lock" : "key"}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{displayName}</p>
<p className="text-sm font-medium break-words sm:truncate">{displayName}</p>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<Badge variant={statusPresentation.statusVariant as any} size="sm" dot>
{statusPresentation.statusLabel}
@ -427,7 +421,7 @@ export default function ConnectionRow({
</span>
)}
{/* Rate Limit Protection — inline toggle with label */}
<span className="text-text-muted/30 select-none">|</span>
<span className="hidden text-text-muted/30 select-none sm:inline">|</span>
<button
onClick={() => onToggleRateLimit(!rateLimitEnabled)}
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${
@ -450,7 +444,7 @@ export default function ConnectionRow({
)}
{onToggleAutoSync && (
<>
<span className="text-text-muted/30 select-none">|</span>
<span className="hidden text-text-muted/30 select-none sm:inline">|</span>
<button
onClick={() => onToggleAutoSync?.(!autoSyncEnabled)}
disabled={connection.isActive === false}
@ -479,7 +473,7 @@ export default function ConnectionRow({
)}
{isClaude && (
<>
<span className="text-text-muted/30 select-none">|</span>
<span className="hidden text-text-muted/30 select-none sm:inline">|</span>
<button
onClick={() => onToggleClaudeExtraUsage?.(!claudeBlockExtraUsageEnabled)}
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${
@ -497,7 +491,7 @@ export default function ConnectionRow({
)}
{isCodex && connection.provider === "codex" && onToggleCodexPaidCredits && (
<>
<span className="text-text-muted/30 select-none">|</span>
<span className="hidden text-text-muted/30 select-none sm:inline">|</span>
<button
onClick={() => onToggleCodexPaidCredits(!codexPaidCreditsEnabled)}
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${
@ -523,7 +517,7 @@ export default function ConnectionRow({
one connection type it was actually built for. */}
{(isClaude || isCcCompatible) && (
<>
<span className="text-text-muted/30 select-none">|</span>
<span className="hidden text-text-muted/30 select-none sm:inline">|</span>
<select
value={effectiveUpstreamProxyMode}
onChange={(e) =>
@ -559,7 +553,7 @@ export default function ConnectionRow({
)}
{isCodex && (
<>
<span className="text-text-muted/30 select-none">|</span>
<span className="hidden text-text-muted/30 select-none sm:inline">|</span>
{codexServiceTierBadge && (
<span
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium ${codexServiceTierBadge.className}`}
@ -599,7 +593,7 @@ export default function ConnectionRow({
)}
{onToggleProxyEnabled && (
<>
<span className="text-text-muted/30 select-none">|</span>
<span className="hidden text-text-muted/30 select-none sm:inline">|</span>
<button
onClick={() => onToggleProxyEnabled(!proxyEnabled)}
aria-label={proxyEnabled ? t("proxyEnabledTitle") : t("proxyDisabledTitle")}
@ -617,7 +611,7 @@ export default function ConnectionRow({
)}
{onTogglePerKeyProxyEnabled && (
<>
<span className="text-text-muted/30 select-none">|</span>
<span className="hidden text-text-muted/30 select-none sm:inline">|</span>
<button
onClick={() => onTogglePerKeyProxyEnabled(!perKeyProxyEnabled)}
aria-label={
@ -661,7 +655,7 @@ export default function ConnectionRow({
: t("proxySourceKey");
return (
<>
<span className="text-text-muted/30 select-none">|</span>
<span className="hidden text-text-muted/30 select-none sm:inline">|</span>
<button
type="button"
onClick={onProxy}
@ -681,163 +675,175 @@ export default function ConnectionRow({
</div>
</div>
</div>
<div className={`flex items-center gap-2 ${isCodex ? "flex-wrap" : ""}`}>
<ConnectionTestButton
connectionId={connection.id}
disabled={connection.isActive === false}
/>
<Button
size="sm"
variant="ghost"
icon="refresh"
loading={isRetesting}
disabled={connection.isActive === false}
onClick={onRetest}
className="!h-7 !px-2 text-xs"
title={t("retestAuthentication")}
>
{t("retest")}
</Button>
{/* T12: Manual token refresh for OAuth accounts */}
{onRefreshToken && (
<Button
size="sm"
variant="ghost"
icon="token"
loading={isRefreshing}
disabled={connection.isActive === false || isRefreshing}
onClick={onRefreshToken}
className="!h-7 !px-2 text-xs text-amber-500 hover:text-amber-400"
title={t("refreshOauthTokenTitle")}
>
{t("tokenShort")}
</Button>
)}
{isCodex && isOAuth && connection.provider === "codex" && (
<CodexPingNowButton
<div className="basis-full min-w-0 xl:basis-auto">
<div className="flex w-full flex-wrap items-center justify-between gap-0.5 sm:gap-1 xl:justify-end [&>button]:grow-0">
<ConnectionTestButton
connectionId={connection.id}
disabled={connection.isActive === false}
/>
)}
{isCodex && onApplyCodexAuthLocal && (
<Button
size="sm"
variant="ghost"
icon="download_done"
loading={isApplyingCodexAuthLocal}
disabled={isApplyingCodexAuthLocal}
onClick={onApplyCodexAuthLocal}
className="!h-7 !px-2 text-xs text-emerald-500 hover:text-emerald-400"
title={applyCodexAuthLabel}
icon="refresh"
loading={isRetesting}
disabled={connection.isActive === false}
onClick={onRetest}
className="!size-7 !p-0 sm:!w-auto sm:!px-2 text-xs"
aria-label={t("retest")}
title={t("retestAuthentication")}
>
{applyCodexAuthLabel}
<span className="hidden sm:inline">{t("retest")}</span>
</Button>
)}
{isCodex && onExportCodexAuthFile && (
<Button
{/* T12: Manual token refresh for OAuth accounts */}
{onRefreshToken && (
<Button
size="sm"
variant="ghost"
icon="token"
loading={isRefreshing}
disabled={connection.isActive === false || isRefreshing}
onClick={onRefreshToken}
className="!size-7 !p-0 sm:!w-auto sm:!px-2 text-xs text-amber-500 hover:text-amber-400"
aria-label={t("tokenShort")}
title={t("refreshOauthTokenTitle")}
>
<span className="hidden sm:inline">{t("tokenShort")}</span>
</Button>
)}
{isCodex && isOAuth && connection.provider === "codex" && (
<CodexPingNowButton
connectionId={connection.id}
disabled={connection.isActive === false}
/>
)}
{isCodex && onApplyCodexAuthLocal && (
<Button
size="sm"
variant="ghost"
icon="download_done"
loading={isApplyingCodexAuthLocal}
disabled={isApplyingCodexAuthLocal}
onClick={onApplyCodexAuthLocal}
className="!size-7 !p-0 sm:!w-auto sm:!px-2 text-xs text-emerald-500 hover:text-emerald-400"
aria-label={applyCodexAuthLabel}
title={applyCodexAuthLabel}
>
<span className="hidden sm:inline">{applyCodexAuthLabel}</span>
</Button>
)}
{isCodex && onExportCodexAuthFile && (
<Button
size="sm"
variant="ghost"
icon="download"
loading={isExportingCodexAuthFile}
disabled={isExportingCodexAuthFile}
onClick={onExportCodexAuthFile}
className="!size-7 !p-0 sm:!w-auto sm:!px-2 text-xs text-sky-500 hover:text-sky-400"
aria-label={exportCodexAuthLabel}
title={exportCodexAuthLabel}
>
<span className="hidden sm:inline">{exportCodexAuthLabel}</span>
</Button>
)}
{isClaude && onApplyClaudeAuthLocal && (
<Button
size="sm"
variant="ghost"
icon="install_desktop"
loading={isApplyingClaudeAuthLocal}
disabled={isApplyingClaudeAuthLocal}
onClick={onApplyClaudeAuthLocal}
className="!size-7 !p-0 sm:!w-auto sm:!px-2 text-xs text-emerald-500 hover:text-emerald-400"
aria-label={applyClaudeAuthLabel}
title={applyClaudeAuthLabel}
>
<span className="hidden sm:inline">{applyClaudeAuthLabel}</span>
</Button>
)}
{isClaude && onExportClaudeAuthFile && (
<Button
size="sm"
variant="ghost"
icon="download"
loading={isExportingClaudeAuthFile}
disabled={isExportingClaudeAuthFile}
onClick={onExportClaudeAuthFile}
className="!size-7 !p-0 sm:!w-auto sm:!px-2 text-xs text-sky-500 hover:text-sky-400"
aria-label={exportClaudeAuthLabel}
title={exportClaudeAuthLabel}
>
<span className="hidden sm:inline">{exportClaudeAuthLabel}</span>
</Button>
)}
<Toggle
size="sm"
variant="ghost"
icon="download"
loading={isExportingCodexAuthFile}
disabled={isExportingCodexAuthFile}
onClick={onExportCodexAuthFile}
className="!h-7 !px-2 text-xs text-sky-500 hover:text-sky-400"
title={exportCodexAuthLabel}
>
{exportCodexAuthLabel}
</Button>
)}
{isClaude && onApplyClaudeAuthLocal && (
<Button
size="sm"
variant="ghost"
icon="install_desktop"
loading={isApplyingClaudeAuthLocal}
disabled={isApplyingClaudeAuthLocal}
onClick={onApplyClaudeAuthLocal}
className="!h-7 !px-2 text-xs text-emerald-500 hover:text-emerald-400"
title={applyClaudeAuthLabel}
>
{applyClaudeAuthLabel}
</Button>
)}
{isClaude && onExportClaudeAuthFile && (
<Button
size="sm"
variant="ghost"
icon="download"
loading={isExportingClaudeAuthFile}
disabled={isExportingClaudeAuthFile}
onClick={onExportClaudeAuthFile}
className="!h-7 !px-2 text-xs text-sky-500 hover:text-sky-400"
title={exportClaudeAuthLabel}
>
{exportClaudeAuthLabel}
</Button>
)}
<Toggle
size="sm"
checked={connection.isActive ?? true}
onChange={onToggleActive}
title={(connection.isActive ?? true) ? t("disableConnection") : t("enableConnection")}
/>
<div className="flex gap-1 ms-1 transition-opacity">
{onReauth && (
checked={connection.isActive ?? true}
onChange={onToggleActive}
title={(connection.isActive ?? true) ? t("disableConnection") : t("enableConnection")}
/>
<div className="contents">
{onReauth && (
<button
onClick={onReauth}
className="inline-flex size-7 items-center justify-center rounded hover:bg-amber-500/10 text-amber-600 hover:text-amber-500"
title={t("reauthenticateConnection")}
>
<span className="material-symbols-outlined text-[18px]">passkey</span>
</button>
)}
{verificationUrl && (
<a
href={verificationUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex size-7 items-center justify-center rounded hover:bg-amber-500/10 text-amber-600 hover:text-amber-500"
title={t("verifyAccountHint")}
aria-label={t("verifyAccount")}
data-testid="connection-verify-account"
>
<span className="material-symbols-outlined text-[18px]">verified_user</span>
</a>
)}
<button
onClick={onReauth}
className="p-2 hover:bg-amber-500/10 rounded text-amber-600 hover:text-amber-500"
title={t("reauthenticateConnection")}
onClick={onEdit}
className="inline-flex size-7 items-center justify-center rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-primary"
title={t("edit")}
>
<span className="material-symbols-outlined text-[18px]">passkey</span>
<span className="material-symbols-outlined text-[18px]">edit</span>
</button>
)}
{verificationUrl && (
<a
href={verificationUrl}
target="_blank"
rel="noopener noreferrer"
className="p-2 hover:bg-amber-500/10 rounded text-amber-600 hover:text-amber-500"
title={t("verifyAccountHint")}
aria-label={t("verifyAccount")}
data-testid="connection-verify-account"
<button
onClick={onProxy}
className="inline-flex size-7 items-center justify-center rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-primary"
title={t("proxyConfig")}
>
<span className="material-symbols-outlined text-[18px]">verified_user</span>
</a>
)}
<button
onClick={onEdit}
className="p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary"
title={t("edit")}
>
<span className="material-symbols-outlined text-[18px]">edit</span>
</button>
<button
onClick={onProxy}
className="p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary"
title={t("proxyConfig")}
>
<span className="material-symbols-outlined text-[18px]">vpn_lock</span>
</button>
<button
onClick={onDelete}
className="p-2 hover:bg-red-500/10 rounded text-red-500"
title={t("delete")}
>
<span className="material-symbols-outlined text-[18px]">delete</span>
</button>
<span className="material-symbols-outlined text-[18px]">vpn_lock</span>
</button>
<button
onClick={onDelete}
className="inline-flex size-7 items-center justify-center rounded hover:bg-red-500/10 text-red-500"
title={t("delete")}
>
<span className="material-symbols-outlined text-[18px]">delete</span>
</button>
</div>
</div>
</div>
{isCodex && connection.codexAccountPool ? (
<CodexAccountDetails pool={connection.codexAccountPool} />
<div className="basis-full min-w-0">
<CodexAccountDetails pool={connection.codexAccountPool} />
</div>
) : null}
{quotaPanelSupported ? (
<ConnectionQuotaPanel
providerId={String(connection.provider || "")}
connection={connection}
cache={quotaCache}
refreshing={quotaRefreshing}
onRefresh={onRefreshQuota}
/>
<div className="basis-full min-w-0">
<ConnectionQuotaPanel
providerId={String(connection.provider || "")}
connection={connection}
cache={quotaCache}
refreshing={quotaRefreshing}
onRefresh={onRefreshQuota}
/>
</div>
) : null}
</div>
);

View file

@ -1,6 +1,6 @@
"use client";
import { Button, DistributeProxiesButton, Toggle } from "@/shared/components";
import { ActionBar, Button, DistributeProxiesButton, Toggle } from "@/shared/components";
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
import type { CodexGlobalServiceMode } from "@/lib/providers/codexFastTier";
import AccountProxyResetButton from "./AccountProxyResetButton";
@ -235,9 +235,7 @@ export default function ConnectionsHeaderToolbar({
: t("providerProxy")}
</button>
</div>
<div
className={`flex flex-wrap items-center gap-2 ${providerId === "codex" ? "justify-start" : "shrink-0 justify-end"}`}
>
<ActionBar align={providerId === "codex" ? "start" : "end"}>
{connections.length > 0 && (
<DistributeProxiesButton
onDistribute={async () => {
@ -405,7 +403,7 @@ export default function ConnectionsHeaderToolbar({
</Button>
)
)}
</div>
</ActionBar>
</div>
);
}

View file

@ -2,7 +2,7 @@
import React from "react";
import { type ConnectionRowConnection } from "./ConnectionRow";
import ConnectionRow from "./ConnectionRow";
import { Button, DistributeProxiesButton } from "@/shared/components";
import { ActionBar, Button, DistributeProxiesButton, FilterPills } from "@/shared/components";
import { pickDisplayValue } from "@/shared/utils/maskEmail";
import { readBooleanToggle, providerCountText } from "../providerPageHelpers";
import { compareTr } from "@/shared/utils/turkishText";
@ -182,7 +182,7 @@ export default function ConnectionsListPanel({
const someSelected = selectedIds.size > 0 && selectedIds.size < connections.length;
const bulkBusy = batchUpdating !== null || batchRetesting || batchDeleting || batchTesting;
const bulkActions = selectedIds.size > 0 && (
<div className="flex flex-wrap items-center justify-end gap-2">
<ActionBar>
<Button
variant="secondary"
size="sm"
@ -223,7 +223,7 @@ export default function ConnectionsListPanel({
>
{t("batchDeleteSelected", { count: selectedIds.size })}
</Button>
</div>
</ActionBar>
);
const isHealthy = (c: ConnectionRowConnection): boolean => {
@ -261,7 +261,7 @@ export default function ConnectionsListPanel({
const pageEnd = pageStart + PAGE_SIZE;
const accountSearchInput = (
<div className="relative min-w-[160px] max-w-[220px]">
<div className="relative w-full sm:w-auto sm:min-w-[160px] sm:max-w-[220px]">
<span className="material-symbols-outlined pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-[15px] text-text-muted">
search
</span>
@ -277,25 +277,15 @@ export default function ConnectionsListPanel({
);
const filterPills = (
<div className="flex items-center gap-1.5 flex-wrap">
{STATUS_FILTER_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => {
setHealthFilter(opt.value);
setPage(0);
setSelectedIds(new Set());
}}
className={`px-2.5 py-1 text-xs rounded-full font-medium transition-colors ${
healthFilter === opt.value
? "bg-primary text-white"
: "bg-muted/60 text-text-muted hover:bg-muted"
}`}
>
{opt.label}
</button>
))}
</div>
<FilterPills
options={STATUS_FILTER_OPTIONS}
value={healthFilter}
onChange={(v) => {
setHealthFilter(v);
setPage(0);
setSelectedIds(new Set());
}}
/>
);
const paginationBar =
@ -334,7 +324,7 @@ export default function ConnectionsListPanel({
return (
<>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 px-3 py-2 bg-muted/50 rounded-t-lg border border-b-0 border-border">
<div className="flex items-center gap-2 flex-wrap">
<div className="flex w-full items-center gap-2 flex-wrap sm:w-auto">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
@ -525,7 +515,7 @@ export default function ConnectionsListPanel({
<>
{selectedIds.size > 0 || connections.length > 0 ? (
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 px-3 py-2 bg-muted/50 rounded-t-lg border border-b-0 border-border">
<div className="flex items-center gap-2 flex-wrap">
<div className="flex w-full items-center gap-2 flex-wrap sm:w-auto">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"

View file

@ -10,7 +10,7 @@
*/
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/shared/components";
import { Button, CapabilityBadge } from "@/shared/components";
import {
normalizeModelSupportedEndpoints,
type ModelSupportedEndpoint,
@ -68,16 +68,23 @@ const MODEL_ENDPOINT_OPTIONS: ModelSupportedEndpoint[] = [
"music",
];
// Layout shared by the add form and the inline edit form: two columns on phones (fields
// pair up, endpoint checkboxes form a tidy grid), one flowing row from lg up.
const FORMAT_FIELDS_GRID = "grid grid-cols-2 items-end gap-3 lg:flex lg:flex-wrap lg:gap-x-4";
const ENDPOINTS_GRID =
"grid grid-cols-2 gap-x-3 gap-y-1.5 sm:grid-cols-3 lg:flex lg:flex-wrap lg:items-center lg:gap-y-1";
const CHECK_LABEL = "flex min-w-0 items-center gap-1.5 text-xs text-text-main cursor-pointer";
function endpointLabel(endpoint: ModelSupportedEndpoint, t: (key: string) => string): string {
const labels: Partial<Record<ModelSupportedEndpoint, string>> = {
chat: `💬 ${t("supportedEndpointChat")}`,
embeddings: `📐 ${t("supportedEndpointEmbeddings")}`,
chat: t("supportedEndpointChat"),
embeddings: t("supportedEndpointEmbeddings"),
rerank: providerText(t, "rerankEndpoint", "Rerank"),
images: `🖼️ ${t("supportedEndpointImages")}`,
videos: "🎬 Video",
"audio-speech": `🔊 ${t("audioSpeech")}`,
"audio-transcriptions": `🎙️ ${t("audioTranscriptions")}`,
music: "🎵 Music",
images: t("supportedEndpointImages"),
videos: "Video",
"audio-speech": t("audioSpeech"),
"audio-transcriptions": t("audioTranscriptions"),
music: "Music",
};
return labels[endpoint] || endpoint;
}
@ -422,8 +429,8 @@ export default function CustomModelsSection({
{/* Add form */}
<div className="flex flex-col gap-3 mb-3">
<div className="flex items-end gap-2">
<div className="flex-1">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-end gap-2 sm:flex sm:flex-wrap">
<div className="col-span-2 flex-1 basis-48 min-w-0">
<label htmlFor="custom-model-id" className="text-xs text-text-muted mb-1 block">
{t("modelId")}
</label>
@ -437,7 +444,7 @@ export default function CustomModelsSection({
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
/>
</div>
<div className="w-40">
<div className="min-w-0 sm:w-40">
<label htmlFor="custom-model-name" className="text-xs text-text-muted mb-1 block">
{t("displayName")}
</label>
@ -457,8 +464,8 @@ export default function CustomModelsSection({
</div>
{/* API Format + Supported Endpoints */}
<div className="flex items-end gap-4 flex-wrap">
<div className="w-48">
<div className={FORMAT_FIELDS_GRID}>
<div className="min-w-0 lg:w-48">
<label htmlFor="custom-api-format" className="text-xs text-text-muted mb-1 block">
API Format
</label>
@ -482,7 +489,7 @@ export default function CustomModelsSection({
<option value="video">Video</option>
</select>
</div>
<div className="w-48">
<div className="min-w-0 lg:w-48">
<label htmlFor="custom-target-format" className="text-xs text-text-muted mb-1 block">
{t("targetFormatLabel")}
</label>
@ -501,16 +508,13 @@ export default function CustomModelsSection({
<option value="antigravity">{t("targetFormatAntigravity")}</option>
</select>
</div>
<div className="flex-1">
<div className="col-span-2 min-w-0 lg:flex-1">
<span className="text-xs text-text-muted mb-1 block">
{t("supportedEndpointsLabel")}
</span>
<div className="flex items-center gap-3">
<div className={ENDPOINTS_GRID}>
{MODEL_ENDPOINT_OPTIONS.map((ep) => (
<label
key={ep}
className="flex items-center gap-1.5 text-xs text-text-main cursor-pointer"
>
<label key={ep} className={CHECK_LABEL}>
<input
type="checkbox"
checked={newEndpoints.includes(ep)}
@ -528,8 +532,8 @@ export default function CustomModelsSection({
))}
</div>
</div>
<div>
<span className="text-xs text-text-muted mb-1 block">&nbsp;</span>
<div className="col-span-2 flex flex-wrap gap-x-4 gap-y-1 lg:block">
<span className="hidden text-xs text-text-muted mb-1 lg:block">&nbsp;</span>
<label
htmlFor="custom-model-supports-vision"
className="flex items-center gap-1.5 text-xs text-text-main cursor-pointer whitespace-nowrap"
@ -542,7 +546,7 @@ export default function CustomModelsSection({
onChange={(e) => setNewSupportsVision(e.target.checked)}
className="rounded border-border"
/>
{`👁️ ${t("visionCapableLabel")}`}
{t("visionCapableLabel")}
</label>
<label
htmlFor="custom-model-is-free"
@ -622,20 +626,22 @@ export default function CustomModelsSection({
</span>
)}
{typeof model.contextWindowOverride === "number" && (
<span
className="text-[10px] px-1.5 py-0.5 rounded-full bg-orange-500/15 text-orange-400 font-medium"
<CapabilityBadge
icon="width_normal"
className="bg-orange-500/15 text-orange-400"
title={t("contextWindowOverrideHint")}
>
{`🪟 ${model.contextWindowOverride.toLocaleString()}`}
</span>
{model.contextWindowOverride.toLocaleString()}
</CapabilityBadge>
)}
{model.supportsVision === true && (
<span
className="text-[10px] px-1.5 py-0.5 rounded-full bg-pink-500/15 text-pink-400 font-medium"
<CapabilityBadge
icon="visibility"
className="bg-pink-500/15 text-pink-400"
title={t("visionCapableHint")}
>
{`👁️ ${t("visionCapableLabel")}`}
</span>
{t("visionCapableLabel")}
</CapabilityBadge>
)}
{model.isFree === true && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-green-500/15 text-green-400 font-medium">
@ -643,35 +649,38 @@ export default function CustomModelsSection({
</span>
)}
{model.supportedEndpoints?.includes("embeddings") && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-purple-500/15 text-purple-400 font-medium">
{`📐 ${t("supportedEndpointEmbeddings")}`}
</span>
<CapabilityBadge
icon="scatter_plot"
className="bg-purple-500/15 text-purple-400"
>
{t("supportedEndpointEmbeddings")}
</CapabilityBadge>
)}
{model.supportedEndpoints?.includes("images") && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-500/15 text-amber-400 font-medium">
{`🖼️ ${t("imagesShortLabel")}`}
</span>
<CapabilityBadge icon="image" className="bg-amber-500/15 text-amber-400">
{t("imagesShortLabel")}
</CapabilityBadge>
)}
{model.supportedEndpoints?.includes("audio") && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-green-500/15 text-green-400 font-medium">
{`🔊 ${t("audioShortLabel")}`}
</span>
<CapabilityBadge icon="volume_up" className="bg-green-500/15 text-green-400">
{t("audioShortLabel")}
</CapabilityBadge>
)}
{(model.supportedEndpoints?.includes("videos") ||
model.supportedEndpoints?.includes("video")) && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-red-500/15 text-red-400 font-medium">
🎬 Video
</span>
<CapabilityBadge icon="movie" className="bg-red-500/15 text-red-400">
Video
</CapabilityBadge>
)}
{model.supportedEndpoints?.includes("audio-speech") && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-green-500/15 text-green-400 font-medium">
{`🔊 ${t("audioSpeech")}`}
</span>
<CapabilityBadge icon="volume_up" className="bg-green-500/15 text-green-400">
{t("audioSpeech")}
</CapabilityBadge>
)}
{model.supportedEndpoints?.includes("audio-transcriptions") && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-cyan-500/15 text-cyan-400 font-medium">
{`🎙️ ${t("audioTranscriptions")}`}
</span>
<CapabilityBadge icon="mic" className="bg-cyan-500/15 text-cyan-400">
{t("audioTranscriptions")}
</CapabilityBadge>
)}
{anyNormalizeCompatBadge(model.id!, customMap, overrideMap) && (
<span
@ -701,8 +710,8 @@ export default function CustomModelsSection({
{editingModelId === model.id && (
<div className="mt-3 min-w-0 max-w-full rounded-lg border border-border bg-muted p-3 dark:bg-zinc-900">
<div className="flex min-w-0 flex-wrap items-end gap-x-3 gap-y-2">
<div className="w-[11rem] shrink-0 min-w-0">
<div className={FORMAT_FIELDS_GRID}>
<div className="min-w-0 lg:w-[11rem] lg:shrink-0">
<label className="text-xs text-text-muted mb-1 block">
{t("apiFormatLabel")}
</label>
@ -728,7 +737,7 @@ export default function CustomModelsSection({
<option value="video">Video</option>
</select>
</div>
<div className="w-[11rem] shrink-0 min-w-0">
<div className="min-w-0 lg:w-[11rem] lg:shrink-0">
<label className="text-xs text-text-muted mb-1 block">
{t("targetFormatLabel")}
</label>
@ -748,7 +757,7 @@ export default function CustomModelsSection({
<option value="antigravity">{t("targetFormatAntigravity")}</option>
</select>
</div>
<div className="w-[10rem] shrink-0 min-w-0">
<div className="min-w-0 lg:w-[10rem] lg:shrink-0">
<label className="text-xs text-text-muted mb-1 block">
{t("contextWindowOverrideLabel")}
</label>
@ -762,11 +771,13 @@ export default function CustomModelsSection({
className="w-full px-2.5 py-2 text-xs border border-border rounded-lg bg-background text-text-main focus:outline-none focus:border-primary"
/>
</div>
<div className="w-[9rem] shrink-0 min-w-0">
<label className="text-xs text-text-muted mb-1 block">&nbsp;</label>
<div className="flex min-w-0 flex-col lg:w-[9rem] lg:shrink-0">
<label className="hidden text-xs text-text-muted mb-1 lg:block">
&nbsp;
</label>
<label
htmlFor={`custom-model-edit-vision-${model.id}`}
className="flex items-center gap-1.5 text-xs text-text-main cursor-pointer whitespace-nowrap px-2.5 py-2"
className="flex items-center gap-1.5 text-xs text-text-main cursor-pointer whitespace-nowrap py-1 lg:px-2.5 lg:py-2"
title={t("visionCapableHint")}
>
<input
@ -776,11 +787,11 @@ export default function CustomModelsSection({
onChange={(e) => setEditingSupportsVision(e.target.checked)}
className="rounded border-border"
/>
{`👁️ ${t("visionCapableLabel")}`}
{t("visionCapableLabel")}
</label>
<label
htmlFor={`custom-model-edit-free-${model.id}`}
className="flex items-center gap-1.5 text-xs text-text-main cursor-pointer whitespace-nowrap px-2.5 py-2"
className="flex items-center gap-1.5 text-xs text-text-main cursor-pointer whitespace-nowrap py-1 lg:px-2.5 lg:py-2"
title="Mark as free-tier"
>
<input
@ -793,16 +804,13 @@ export default function CustomModelsSection({
FREE
</label>
</div>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1 overflow-x-auto overflow-y-visible [scrollbar-width:thin]">
<div className="col-span-2 flex min-w-0 flex-col gap-1 lg:flex-1 lg:flex-row lg:flex-wrap lg:items-center lg:gap-x-3">
<span className="text-xs text-text-muted shrink-0">
{t("supportedEndpointsLabel")}
</span>
<div className="flex flex-wrap items-center gap-x-2 sm:gap-x-3 gap-y-1 min-w-0">
<div className={ENDPOINTS_GRID}>
{MODEL_ENDPOINT_OPTIONS.map((ep) => (
<label
key={ep}
className="flex items-center gap-1.5 text-xs text-text-main cursor-pointer whitespace-nowrap"
>
<label key={ep} className={CHECK_LABEL}>
<input
type="checkbox"
checked={editingEndpoints.includes(ep)}
@ -822,7 +830,7 @@ export default function CustomModelsSection({
))}
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2 pb-0.5">
<div className="col-span-2 flex flex-wrap items-center gap-2 pb-0.5 lg:shrink-0">
<Button
size="sm"
onClick={() => saveEdit(model.id!)}

View file

@ -528,11 +528,12 @@ export default function ModelCompatPopover({
type="button"
onClick={handleToggleOpen}
disabled={disabled}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-lg border border-border bg-background text-text-muted hover:bg-muted hover:text-text-main disabled:opacity-50 transition-colors"
className="inline-flex items-center gap-1.5 px-1.5 py-1.5 text-xs font-medium rounded-lg border sm:px-2.5 border-border bg-background text-text-muted hover:bg-muted hover:text-text-main disabled:opacity-50 transition-colors"
title={t("compatAdjustmentsTitle")}
>
<span className="material-symbols-outlined text-base leading-none">tune</span>
{!compact && t("compatButtonLabel")}
{/* Phones: icon only (the title keeps the label), so a model card fits in two lines. */}
{!compact && <span className="hidden sm:inline">{t("compatButtonLabel")}</span>}
</button>
{open &&
typeof document !== "undefined" &&

View file

@ -62,7 +62,7 @@ function getModelSourceBadgeClass(source?: string): string {
export function ModelSourceBadge({ source }: { source?: string }) {
return (
<span
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide ${getModelSourceBadgeClass(
className={`shrink-0 whitespace-nowrap rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide ${getModelSourceBadgeClass(
source
)}`}
>
@ -124,7 +124,7 @@ export function ModelVisibilityToolbar({
}: ModelVisibilityToolbarProps) {
return (
<div className="mb-3 flex flex-wrap items-center gap-2">
<div className="relative min-w-[220px] flex-1">
<div className="relative w-full min-w-0 flex-1 sm:w-auto sm:min-w-[220px]">
<span className="material-symbols-outlined pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-[15px] text-text-muted">
search
</span>
@ -137,7 +137,7 @@ export function ModelVisibilityToolbar({
/>
</div>
{visibilityFilter !== undefined && onVisibilityFilterChange && (
<div className="flex items-center gap-1 rounded-lg border border-border bg-sidebar/50 p-0.5">
<div className="flex w-full items-center gap-1 rounded-lg border border-border bg-sidebar/50 p-0.5 sm:w-auto [&>button]:grow sm:[&>button]:grow-0">
{(["all", "visible", "hidden"] as const).map((f) => (
<button
key={f}
@ -158,7 +158,7 @@ export function ModelVisibilityToolbar({
</div>
)}
{freeFilter !== undefined && onFreeFilterChange && (
<div className="flex items-center gap-1 rounded-lg border border-border bg-sidebar/50 p-0.5">
<div className="flex w-full items-center gap-1 rounded-lg border border-border bg-sidebar/50 p-0.5 sm:w-auto [&>button]:grow sm:[&>button]:grow-0">
{(["all", "free", "paid"] as const).map((f) => (
<button
key={f}
@ -347,26 +347,36 @@ export default function ModelRow({
return (
<div
className={`flex min-w-[220px] max-w-md items-center gap-2 rounded-lg border border-border px-3 py-2 hover:bg-sidebar/50 transition-opacity ${
className={`relative flex h-full w-full min-w-0 flex-col gap-1.5 rounded-lg border border-border px-3 py-2 hover:bg-sidebar/50 transition-opacity ${
isHidden ? "opacity-50" : ""
}`}
>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
{/* Line 1: icon + full model name (wraps, never cut); copy pinned to the top-right corner. */}
<div className="flex min-w-0 items-start gap-2 pr-7">
<span
className="material-symbols-outlined shrink-0 text-base"
className="material-symbols-outlined mt-0.5 shrink-0 text-base"
style={{ color: isHidden ? "var(--color-text-muted)" : undefined }}
>
smart_toy
</span>
<code className="rounded bg-sidebar px-1.5 py-0.5 font-mono text-xs text-text-muted">
<code className="min-w-0 break-all pt-px font-mono text-xs leading-5 text-text-main">
{fullModel}
</code>
</div>
<button
onClick={() => onCopy(fullModel, `model-${model.id}`)}
className="absolute right-2 top-2 rounded p-0.5 text-text-muted hover:bg-sidebar hover:text-primary"
title={t("copyModel")}
>
<span className="material-symbols-outlined text-sm">
{copied === `model-${model.id}` ? "check" : "content_copy"}
</span>
</button>
{/* Line 2 (never wraps): source, alias (truncates) and the actions, in the name's column. */}
<div className="flex min-w-0 items-center gap-2 pl-6">
<ModelSourceBadge source={model.source} />
{endpointInfo && (
<ModelEndpointTagsEditor provider={provider} modelId={model.id} t={t} {...endpointInfo} />
)}
{onSetAlias && (
<span className="flex min-w-0 items-center text-[9px] gap-1">
<span className="flex min-w-0 flex-1 items-center text-[10px] gap-1">
{editing ? (
<input
ref={inputRef}
@ -380,7 +390,7 @@ export default function ModelRow({
/>
) : (
<span
className={`truncate text-[9px] italic cursor-pointer hover:text-primary transition-colors ${alias ? "text-primary/80" : "text-text-muted/70"}`}
className={`min-w-0 truncate text-[10px] italic cursor-pointer hover:text-primary transition-colors ${alias ? "text-primary/80" : "text-text-muted/70"}`}
onClick={startEditing}
title={
alias
@ -395,96 +405,93 @@ export default function ModelRow({
)}
</span>
)}
<button
onClick={() => onCopy(fullModel, `model-${model.id}`)}
className="rounded p-0.5 text-text-muted hover:bg-sidebar hover:text-primary"
title={t("copyModel")}
>
<span className="material-symbols-outlined text-sm">
{copied === `model-${model.id}` ? "check" : "content_copy"}
</span>
</button>
</div>
<div className="flex shrink-0 items-center gap-1">
{onToggleHidden && testStatus === "error" && (
<button
onClick={() => onToggleHidden(model.id, true)}
disabled={togglingHidden}
className={`flex items-center gap-1 rounded p-0.5 text-[11px] text-red-500 hover:bg-sidebar transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${togglingHidden ? "animate-pulse" : ""}`}
title={providerText(
t,
"blacklistModelTooltip",
"Blacklist this model — hide it from the catalog"
)}
>
<span className="material-symbols-outlined text-sm">
{togglingHidden ? "progress_activity" : "block"}
</span>
<span>{providerText(t, "blacklistModel", "Blacklist")}</span>
</button>
)}
{onTestModel && (
<button
onClick={() => onTestModel(model.id, fullModel)}
disabled={testingModel}
className={`rounded p-0.5 hover:bg-sidebar transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${testStatus === "ok" ? "text-green-500" : testStatus === "quota" ? "text-amber-500" : testStatus === "error" ? "text-red-500" : "text-text-muted hover:text-primary"}`}
title={
testingModel
? t("testingModel")
: testStatus === "ok"
? "OK"
: testStatus === "quota"
? t("modelTestQuotaTooltip")
: testStatus === "error"
? providerText(t, "errorShort", "Error")
: t("testModel")
}
>
{testingModel ? (
<span className="material-symbols-outlined text-sm animate-spin">
progress_activity
<div className="ml-auto flex shrink-0 items-center gap-1">
{onToggleHidden && testStatus === "error" && (
<button
onClick={() => onToggleHidden(model.id, true)}
disabled={togglingHidden}
className={`flex items-center gap-1 rounded p-0.5 text-[11px] text-red-500 hover:bg-sidebar transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${togglingHidden ? "animate-pulse" : ""}`}
title={providerText(
t,
"blacklistModelTooltip",
"Blacklist this model — hide it from the catalog"
)}
>
<span className="material-symbols-outlined text-sm">
{togglingHidden ? "progress_activity" : "block"}
</span>
) : testStatus === "ok" ? (
<span className="material-symbols-outlined text-sm">check_circle</span>
) : testStatus === "quota" ? (
<span className="material-symbols-outlined text-sm">warning</span>
) : testStatus === "error" ? (
<span className="material-symbols-outlined text-sm">error</span>
) : (
<span className="material-symbols-outlined text-sm">play_circle</span>
)}
</button>
)}
{onToggleHidden && (
<button
onClick={() => onToggleHidden(model.id, !isHidden)}
disabled={togglingHidden}
className="rounded p-0.5 text-text-muted hover:bg-sidebar hover:text-primary disabled:opacity-40 disabled:cursor-not-allowed"
title={
isHidden
? providerText(t, "showModel", "Show model")
: providerText(t, "hideModel", "Hide model")
<span>{providerText(t, "blacklistModel", "Blacklist")}</span>
</button>
)}
{onTestModel && (
<button
onClick={() => onTestModel(model.id, fullModel)}
disabled={testingModel}
className={`rounded p-0.5 hover:bg-sidebar transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${testStatus === "ok" ? "text-green-500" : testStatus === "quota" ? "text-amber-500" : testStatus === "error" ? "text-red-500" : "text-text-muted hover:text-primary"}`}
title={
testingModel
? t("testingModel")
: testStatus === "ok"
? "OK"
: testStatus === "quota"
? t("modelTestQuotaTooltip")
: testStatus === "error"
? providerText(t, "errorShort", "Error")
: t("testModel")
}
>
{testingModel ? (
<span className="material-symbols-outlined text-sm animate-spin">
progress_activity
</span>
) : testStatus === "ok" ? (
<span className="material-symbols-outlined text-sm">check_circle</span>
) : testStatus === "quota" ? (
<span className="material-symbols-outlined text-sm">warning</span>
) : testStatus === "error" ? (
<span className="material-symbols-outlined text-sm">error</span>
) : (
<span className="material-symbols-outlined text-sm">play_circle</span>
)}
</button>
)}
{onToggleHidden && (
<button
onClick={() => onToggleHidden(model.id, !isHidden)}
disabled={togglingHidden}
className="rounded p-0.5 text-text-muted hover:bg-sidebar hover:text-primary disabled:opacity-40 disabled:cursor-not-allowed"
title={
isHidden
? providerText(t, "showModel", "Show model")
: providerText(t, "hideModel", "Hide model")
}
>
<span className="material-symbols-outlined text-sm">
{isHidden ? "visibility_off" : "visibility"}
</span>
</button>
)}
<ModelCompatPopover
t={t}
providerId={provider}
modelId={model.id}
effectiveModelNormalize={(p) => effectiveModelNormalize(model.id, p)}
effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(model.id, p)}
getUpstreamHeadersRecord={getUpstreamHeadersRecord}
onCompatPatch={(protocol, payload) =>
saveModelCompatFlags(model.id, { compatByProtocol: { [protocol]: payload } })
}
>
<span className="material-symbols-outlined text-sm">
{isHidden ? "visibility_off" : "visibility"}
</span>
</button>
)}
<ModelCompatPopover
t={t}
providerId={provider}
modelId={model.id}
effectiveModelNormalize={(p) => effectiveModelNormalize(model.id, p)}
effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(model.id, p)}
getUpstreamHeadersRecord={getUpstreamHeadersRecord}
onCompatPatch={(protocol, payload) =>
saveModelCompatFlags(model.id, { compatByProtocol: { [protocol]: payload } })
}
showDeveloperToggle={showDeveloperToggle}
disabled={compatDisabled}
/>
showDeveloperToggle={showDeveloperToggle}
disabled={compatDisabled}
/>
</div>
</div>
{/* Line 3: endpoint capability tags (#16929) wrap freely, so line 2 stays one row. */}
{endpointInfo && (
<div className="min-w-0 pl-6">
<ModelEndpointTagsEditor provider={provider} modelId={model.id} t={t} {...endpointInfo} />
</div>
)}
</div>
);
}

View file

@ -9,7 +9,7 @@
* Never imports from ProviderDetailPageClient.
*/
import React, { useState, useRef, useEffect } from "react";
import { Badge } from "@/shared/components";
import { Badge, CapabilityBadge } from "@/shared/components";
import { parseContextWindowOverrideInput, providerText } from "../providerPageHelpers";
import ModelCompatPopover from "./ModelCompatPopover";
import ModelEndpointTagsEditor, { type ModelEndpointEditorInfo } from "./ModelEndpointTagsEditor";
@ -260,15 +260,16 @@ export default function PassthroughModelRow({
{providerText(t, "freeBadge", "Free")}
</Badge>
)}
{/* #14337: the same 🪟 badge custom rows carry, so an override set here
is visible after reload instead of being write-only. */}
{/* #14337: the same context-window badge custom rows carry, so an override set
here is visible after reload instead of being write-only. */}
{typeof contextWindowOverride === "number" && !editingContext && (
<span
className="shrink-0 rounded-full bg-orange-500/15 px-1.5 py-0.5 text-[10px] font-medium text-orange-400"
<CapabilityBadge
icon="width_normal"
className="shrink-0 bg-orange-500/15 text-orange-400"
title={t("contextWindowOverrideHint")}
>
{`🪟 ${contextWindowOverride.toLocaleString()}`}
</span>
{contextWindowOverride.toLocaleString()}
</CapabilityBadge>
)}
{editingContext && (
<span className="flex items-center gap-1">

View file

@ -12,7 +12,7 @@
*/
import { useState } from "react";
import { Button } from "@/shared/components";
import { ActionBar, AutoGrid, Button } from "@/shared/components";
import { matchesModelCatalogQuery } from "@/shared/utils/modelCatalogSearch";
import { isFreeModel, sortModelsFreeFirst } from "@/shared/utils/freeModels";
import {
@ -271,10 +271,10 @@ export default function ProviderModelsSection({
return (
<div>
<div className="flex items-center gap-2 mb-4">
<ActionBar align="start" className="mb-4">
{modelDiscoveryControls}
{clearAllButton}
</div>
</ActionBar>
<CompatibleModelsSection
providerStorageAlias={providerStorageAlias}
providerDisplayAlias={providerDisplayAlias}
@ -340,7 +340,7 @@ export default function ProviderModelsSection({
return (
<div>
<div className="flex items-center gap-2 mb-4">
<ActionBar align="start" className="mb-4">
{allowModelImport && (
<Button
size="sm"
@ -357,7 +357,7 @@ export default function ProviderModelsSection({
{allowModelImport && !canImportModels && (
<span className="text-xs text-text-muted">{t("addConnectionToImport")}</span>
)}
</div>
</ActionBar>
<PassthroughModelsSection
providerAlias={providerAlias}
modelAliases={modelAliases}
@ -401,7 +401,7 @@ export default function ProviderModelsSection({
}
const importButton = allowModelImport ? (
<div className="flex items-center gap-2 mb-4">
<ActionBar align="start" className="mb-4">
<Button
size="sm"
variant="secondary"
@ -415,7 +415,7 @@ export default function ProviderModelsSection({
{!canImportModels && (
<span className="text-xs text-text-muted">{t("addConnectionToImport")}</span>
)}
</div>
</ActionBar>
) : null;
if (models.length === 0) {
@ -510,7 +510,7 @@ export default function ProviderModelsSection({
onSortFreeFirstChange={setSortFreeFirst}
/>
)}
<div className="flex flex-wrap gap-3">
<AutoGrid>
{displayModels.map((model) => {
return (
<ModelRow
@ -563,13 +563,13 @@ export default function ProviderModelsSection({
);
})}
{filteredModels.length === 0 && modelFilter && (
<p className="text-sm text-text-muted py-2">
<p className="col-span-full text-sm text-text-muted py-2">
{providerText(t, "noModelsMatch", `No models match "${modelFilter}"`, {
filter: modelFilter,
})}
</p>
)}
</div>
</AutoGrid>
</div>
);
}

View file

@ -20,7 +20,7 @@ export default function ProviderQuotaVisibilityToggle({
return (
<>
<span className="text-text-muted/30 select-none">|</span>
<span className="hidden text-text-muted/30 select-none sm:inline">|</span>
<button
type="button"
onClick={() => onToggle(!visible)}

View file

@ -1,6 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
import { ProviderCardGrid } from "@/shared/components";
import { getProviderAlias } from "@/shared/constants/providers";
import type { ProviderEntry } from "../providerPageUtils";
import ProviderCard from "./ProviderCard";
@ -109,7 +110,7 @@ export default function NoAuthProvidersSection({
</button>
</div>
<p className="text-sm text-text-muted -mt-2">{t("noAuthProvidersDesc")}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
<ProviderCardGrid>
{visibleEntries.map(({ providerId, provider, stats, toggleAuthType }) => (
<ProviderCard
key={providerId}
@ -120,13 +121,13 @@ export default function NoAuthProvidersSection({
onToggle={(active) => onToggleProvider(providerId, toggleAuthType, active)}
/>
))}
</div>
</ProviderCardGrid>
{blockedEntries.length > 0 && (
<div className="flex flex-col gap-2">
<p className="text-xs font-medium uppercase tracking-wide text-text-muted">
{t("disabled")}
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
<ProviderCardGrid>
{blockedEntries.map(({ providerId, provider }) => (
<div
key={providerId}
@ -151,7 +152,7 @@ export default function NoAuthProvidersSection({
</button>
</div>
))}
</div>
</ProviderCardGrid>
</div>
)}
</div>

View file

@ -79,7 +79,7 @@ export default function ProviderDisplayModeControl({
return (
<div
className="flex items-center rounded-lg border border-border bg-bg-subtle p-0.5"
className="flex w-full items-center rounded-lg border border-border bg-bg-subtle p-0.5 sm:w-auto"
role="radiogroup"
aria-label={providerText(t, "providerDisplayMode", "Provider display mode")}
data-testid="provider-display-mode-control"
@ -92,7 +92,7 @@ export default function ProviderDisplayModeControl({
title={option.title}
data-testid={`provider-display-mode-${option.mode}`}
data-active={isActive ? "true" : "false"}
className={`inline-flex h-7 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium transition-colors ${
className={`inline-flex h-7 grow items-center justify-center gap-1.5 rounded-md px-2.5 text-xs font-medium transition-colors sm:grow-0 ${
isActive
? "bg-bg-primary text-text-main shadow-sm"
: "text-text-muted hover:bg-bg-primary/70 hover:text-text-main"

View file

@ -247,7 +247,7 @@ export default function ProviderSummaryCard({
</button>
</div>
<div className="border-t border-border pt-3 flex flex-wrap items-center gap-2">
<div className="border-t border-border pt-3 flex flex-wrap items-center gap-2 [&>button]:grow [&>button]:justify-center sm:[&>button]:grow-0">
{categories.map((cat) => {
const isActive =
(cat.key === null && !activeCategory && !showFreeOnly) ||
@ -278,7 +278,7 @@ export default function ProviderSummaryCard({
})}
</div>
<div className="border-t border-border pt-3 flex flex-wrap items-center gap-2">
<div className="border-t border-border pt-3 flex flex-wrap items-center gap-2 [&>button]:grow [&>button]:justify-center sm:[&>button]:grow-0">
<span className="text-[11px] font-medium uppercase tracking-wide text-text-muted mr-1">
{providerText(t, "filterByMedia", "Media")}
</span>

View file

@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useMemo, Suspense } from "react";
import { Card, CardSkeleton, Badge, Button, CollapsibleSection } from "@/shared/components";
import { Card, CardSkeleton, Button, ProviderCardGrid } from "@/shared/components";
import {
AGGREGATOR_PROVIDER_IDS,
EMBEDDING_RERANK_PROVIDER_IDS,
@ -1073,7 +1073,7 @@ function ProvidersPageContent() {
<span>{t("noCompatibleYet")}</span>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
<ProviderCardGrid>
{compatibleProviderEntries.map(
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
<HighlightableProviderCard
@ -1088,7 +1088,7 @@ function ProvidersPageContent() {
/>
)
)}
</div>
</ProviderCardGrid>
)}
</div>
)}
@ -1146,7 +1146,7 @@ function ProvidersPageContent() {
</div>
</div>
<p className="text-sm text-text-muted -mt-2">{t("oauthProvidersDesc")}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
<ProviderCardGrid>
{oauthProviderEntries
.filter((e) => !IDE_PROVIDER_IDS.has(e.providerId))
.map(({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
@ -1161,7 +1161,7 @@ function ProvidersPageContent() {
}
/>
))}
</div>
</ProviderCardGrid>
</div>
)}
@ -1205,7 +1205,7 @@ function ProvidersPageContent() {
{t("noIdeProviders") || "No IDE providers match the current filters."}
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
<ProviderCardGrid>
{ideProviderEntries.map(
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
<HighlightableProviderCard
@ -1220,7 +1220,7 @@ function ProvidersPageContent() {
/>
)
)}
</div>
</ProviderCardGrid>
)}
</div>
)}
@ -1256,7 +1256,7 @@ function ProvidersPageContent() {
</button>
</div>
<p className="text-sm text-text-muted -mt-2">{t("webCookieProvidersDesc")}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
<ProviderCardGrid>
{webCookieProviderEntries.map(
({ providerId, provider, stats, toggleAuthType }) => (
<HighlightableProviderCard
@ -1271,7 +1271,7 @@ function ProvidersPageContent() {
/>
)
)}
</div>
</ProviderCardGrid>
</div>
)}
@ -1305,7 +1305,7 @@ function ProvidersPageContent() {
{testingMode === "free" ? t("testing") : t("testAll")}
</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
<ProviderCardGrid>
{freeSectionEntries.map(
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
<HighlightableProviderCard
@ -1320,7 +1320,7 @@ function ProvidersPageContent() {
/>
)
)}
</div>
</ProviderCardGrid>
</div>
)}
@ -1358,7 +1358,7 @@ function ProvidersPageContent() {
<h3 className="text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("llmProviders")}
</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
<ProviderCardGrid>
{llmProviderEntries.map(
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
<HighlightableProviderCard
@ -1373,7 +1373,7 @@ function ProvidersPageContent() {
/>
)
)}
</div>
</ProviderCardGrid>
</div>
)}
</div>
@ -1427,7 +1427,7 @@ function ProvidersPageContent() {
</button>
</div>
<p className="text-sm text-text-muted -mt-2">{t("upstreamProxyProvidersDesc")}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
<ProviderCardGrid>
{upstreamProxyEntries.map(({ providerId, provider, stats, toggleAuthType }) => (
<HighlightableProviderCard
key={providerId}
@ -1440,7 +1440,7 @@ function ProvidersPageContent() {
}
/>
))}
</div>
</ProviderCardGrid>
</div>
)}
@ -1458,7 +1458,7 @@ function ProvidersPageContent() {
</h2>
</div>
<p className="text-sm text-text-muted -mt-2">{t("webFetchProvidersDesc")}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
<ProviderCardGrid>
{visibleWebFetchEntries.map(
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
<HighlightableProviderCard
@ -1473,7 +1473,7 @@ function ProvidersPageContent() {
/>
)
)}
</div>
</ProviderCardGrid>
</div>
)}
@ -1491,7 +1491,7 @@ function ProvidersPageContent() {
</h2>
</div>
<p className="text-sm text-text-muted -mt-2">{t("aggregatorsGatewaysDesc")}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
<ProviderCardGrid>
{aggregatorProviderEntries.map(
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
<HighlightableProviderCard
@ -1506,7 +1506,7 @@ function ProvidersPageContent() {
/>
)
)}
</div>
</ProviderCardGrid>
</div>
)}
@ -1524,7 +1524,7 @@ function ProvidersPageContent() {
</h2>
</div>
<p className="text-sm text-text-muted -mt-2">{t("enterpriseCloudDesc")}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
<ProviderCardGrid>
{enterpriseProviderEntries.map(
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
<HighlightableProviderCard
@ -1539,7 +1539,7 @@ function ProvidersPageContent() {
/>
)
)}
</div>
</ProviderCardGrid>
</div>
)}
@ -1574,7 +1574,7 @@ function ProvidersPageContent() {
</button>
</div>
<p className="text-sm text-text-muted -mt-2">{t("cloudAgentProvidersDesc")}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
<ProviderCardGrid>
{cloudAgentProviderEntries.map(
({ providerId, provider, stats, toggleAuthType }) => (
<HighlightableProviderCard
@ -1589,7 +1589,7 @@ function ProvidersPageContent() {
/>
)
)}
</div>
</ProviderCardGrid>
</div>
)}
@ -1624,7 +1624,7 @@ function ProvidersPageContent() {
</button>
</div>
<p className="text-sm text-text-muted -mt-2">{t("localProvidersDesc")}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
<ProviderCardGrid>
{localProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => (
<HighlightableProviderCard
key={providerId}
@ -1637,7 +1637,7 @@ function ProvidersPageContent() {
}
/>
))}
</div>
</ProviderCardGrid>
</div>
)}
@ -1672,7 +1672,7 @@ function ProvidersPageContent() {
</button>
</div>
<p className="text-sm text-text-muted -mt-2">{t("searchProvidersDesc")}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
<ProviderCardGrid>
{searchProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => (
<HighlightableProviderCard
key={providerId}
@ -1685,7 +1685,7 @@ function ProvidersPageContent() {
}
/>
))}
</div>
</ProviderCardGrid>
</div>
)}
@ -1703,7 +1703,7 @@ function ProvidersPageContent() {
</h2>
</div>
<p className="text-sm text-text-muted -mt-2">{t("embeddingRerankProvidersDesc")}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
<ProviderCardGrid>
{embeddingRerankProviderEntries.map(
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
<HighlightableProviderCard
@ -1718,7 +1718,7 @@ function ProvidersPageContent() {
/>
)
)}
</div>
</ProviderCardGrid>
</div>
)}
@ -1736,7 +1736,7 @@ function ProvidersPageContent() {
</h2>
</div>
<p className="text-sm text-text-muted -mt-2">{t("imageProvidersDesc")}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
<ProviderCardGrid>
{imageProviderEntries.map(
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
<HighlightableProviderCard
@ -1751,7 +1751,7 @@ function ProvidersPageContent() {
/>
)
)}
</div>
</ProviderCardGrid>
</div>
)}
@ -1786,7 +1786,7 @@ function ProvidersPageContent() {
</button>
</div>
<p className="text-sm text-text-muted -mt-2">{t("audioProvidersDesc")}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
<ProviderCardGrid>
{audioProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => (
<HighlightableProviderCard
key={providerId}
@ -1799,7 +1799,7 @@ function ProvidersPageContent() {
}
/>
))}
</div>
</ProviderCardGrid>
</div>
)}
@ -1817,7 +1817,7 @@ function ProvidersPageContent() {
</h2>
</div>
<p className="text-sm text-text-muted -mt-2">{t("videoProvidersDesc")}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
<ProviderCardGrid>
{videoProviderEntries.map(
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
<HighlightableProviderCard
@ -1832,7 +1832,7 @@ function ProvidersPageContent() {
/>
)
)}
</div>
</ProviderCardGrid>
</div>
)}
</>

View file

@ -23,11 +23,26 @@ interface ConceptItem {
}
const CONCEPTS: ConceptItem[] = [
{ icon: "🔍", key: "search", titleKey: "searchConceptTitle", descKey: "searchConceptDesc" },
{ icon: "📄", key: "scrape", titleKey: "scrapeConceptTitle", descKey: "scrapeConceptDesc" },
{ icon: "⚖", key: "compare", titleKey: "compareConceptTitle", descKey: "compareConceptDesc" },
{ icon: "↕", key: "rerank", titleKey: "rerankConceptTitle", descKey: "rerankConceptDesc" },
{ icon: "⚡", key: "auto", titleKey: "autoConceptTitle", descKey: "autoConceptDesc" },
{ icon: "search", key: "search", titleKey: "searchConceptTitle", descKey: "searchConceptDesc" },
{
icon: "description",
key: "scrape",
titleKey: "scrapeConceptTitle",
descKey: "scrapeConceptDesc",
},
{
icon: "balance",
key: "compare",
titleKey: "compareConceptTitle",
descKey: "compareConceptDesc",
},
{
icon: "swap_vert",
key: "rerank",
titleKey: "rerankConceptTitle",
descKey: "rerankConceptDesc",
},
{ icon: "bolt", key: "auto", titleKey: "autoConceptTitle", descKey: "autoConceptDesc" },
];
interface SearchConceptCardProps {
@ -73,7 +88,10 @@ export default function SearchConceptCard({ defaultCollapsed = false }: SearchCo
className="flex gap-3 p-3 bg-bg-alt rounded-lg border border-border"
data-testid={`concept-item-${c.key}`}
>
<span className="text-lg shrink-0" aria-hidden="true">
<span
className="material-symbols-outlined shrink-0 text-[20px] text-primary"
aria-hidden="true"
>
{c.icon}
</span>
<div>

View file

@ -15,11 +15,12 @@ interface SearchToolsTopBarProps {
exportState?: PlaygroundState;
}
const TABS: { id: ActiveTab; icon: string; labelKey: "tabSearch" | "tabScrape" | "tabCompare" }[] = [
{ id: "search", icon: "🔍", labelKey: "tabSearch" },
{ id: "scrape", icon: "📄", labelKey: "tabScrape" },
{ id: "compare", icon: "⚖", labelKey: "tabCompare" },
];
const TABS: { id: ActiveTab; icon: string; labelKey: "tabSearch" | "tabScrape" | "tabCompare" }[] =
[
{ id: "search", icon: "search", labelKey: "tabSearch" },
{ id: "scrape", icon: "description", labelKey: "tabScrape" },
{ id: "compare", icon: "balance", labelKey: "tabCompare" },
];
export default function SearchToolsTopBar({
activeTab,
@ -56,7 +57,9 @@ export default function SearchToolsTopBar({
onClick={() => onTabChange(tab.id)}
data-testid={`tab-${tab.id}`}
>
<span aria-hidden="true">{tab.icon}</span>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
{tab.icon}
</span>
<span>{t(tab.labelKey)}</span>
</button>
))}

View file

@ -124,6 +124,7 @@ export default function ProviderAccountRoutingCard({ providerKey, connectionCoun
<p className="text-xs text-text-muted mb-3">{t("providerAccountRoutingDesc")}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Select
compact
label={t("providerRoutingStrategy")}
disabled={busy}
value={strategy}

View file

@ -48,7 +48,11 @@ export function CaptureModesToolbar({ customHostCount }: CaptureModesToolbarProp
fetchTproxyStatus()
.then((s) => {
if (alive) {
setTproxy({ running: s.running, available: s.available, interceptCount: s.interceptCount });
setTproxy({
running: s.running,
available: s.available,
interceptCount: s.interceptCount,
});
}
})
.catch(() => {
@ -134,7 +138,14 @@ export function CaptureModesToolbar({ customHostCount }: CaptureModesToolbarProp
)}
/>
{label}
{warn && <span className="text-amber-400">⚠</span>}
{warn && (
<span
className="material-symbols-outlined text-[14px] leading-none text-amber-400"
aria-hidden="true"
>
warning
</span>
)}
</button>
);
})}
@ -162,7 +173,12 @@ export function CaptureModesToolbar({ customHostCount }: CaptureModesToolbarProp
{tproxy.running && typeof tproxy.interceptCount === "number" && (
<span className="text-amber-400">· {tproxy.interceptCount}</span>
)}
<span className="text-amber-400">⚠</span>
<span
className="material-symbols-outlined text-[14px] leading-none text-amber-400"
aria-hidden="true"
>
warning
</span>
</button>
<div className="ml-auto flex items-center gap-2">
@ -171,7 +187,10 @@ export function CaptureModesToolbar({ customHostCount }: CaptureModesToolbarProp
onClick={() => setShowHosts(true)}
className="text-xs text-text-muted hover:text-text-main focus-ring rounded"
>
⚙ {t("manageHosts")}
<span className="material-symbols-outlined text-[14px] leading-none" aria-hidden="true">
settings
</span>{" "}
{t("manageHosts")}
</button>
<button
type="button"

View file

@ -76,7 +76,13 @@ export function RequestRow({ request, selected, onClick, onSameContext, style }:
className="text-text-muted shrink-0 font-mono opacity-70 truncate max-w-[120px]"
title={request.pid ? `PID ${request.pid}` : undefined}
>
⚙ {request.processName}
<span
className="material-symbols-outlined text-[14px] leading-none"
aria-hidden="true"
>
settings
</span>{" "}
{request.processName}
</span>
)}
</div>

View file

@ -7,7 +7,9 @@
the layout. Bundling the font locally removes that runtime CDN dependency.
The bundled @font-face uses family "Material Symbols Outlined", matching the
.material-symbols-outlined rule defined later in this file. */
@import "material-symbols/outlined.css";
/* Layered as base so Tailwind size utilities (text-sm, text-[13px]…) on an icon win;
unlayered, the package's `font-size: 24px` beat every utility and all icons rendered at 24px. */
@import "material-symbols/outlined.css" layer(base);
/* react18-json-view (request/response JSON tree in RequestLoggerDetail) — imported here
instead of in the component so node:test / esbuild never see a .css import. */
@import "react18-json-view/src/style.css";
@ -369,27 +371,70 @@ body.electron-macos {
}
/* Material Symbols */
:where(.material-symbols-outlined) {
font-family: "Material Symbols Outlined", sans-serif !important;
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
font-feature-settings: "liga";
-webkit-font-feature-settings: "liga";
-webkit-font-smoothing: antialiased;
font-variation-settings:
"FILL" 0,
"wght" 400,
"GRAD" 0,
"opsz" 24;
user-select: none;
@layer base {
:where(.material-symbols-outlined) {
font-family: "Material Symbols Outlined", sans-serif !important;
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
font-feature-settings: "liga";
-webkit-font-feature-settings: "liga";
-webkit-font-smoothing: antialiased;
font-variation-settings:
"FILL" 0,
"wght" 400,
"GRAD" 0,
"opsz" 24;
user-select: none;
}
}
/* Phones: any wrapping row of 3+ buttons, and any row made only of 3+ buttons (tabs,
filters, toolbars), stretches its buttons to fill each line instead of leaving a ragged
gap on the right. Same behaviour as the shared ActionBar / FilterPills; a component
opts out with `grow-0` on the button. */
@layer components {
@media (max-width: 639px) {
main .flex-wrap:has(> button:nth-of-type(3)) > button,
main .flex:has(> button:nth-of-type(3)):not(:has(> :not(button))) > button {
flex-grow: 1;
justify-content: center;
}
}
}
/* Phones: page and section titles stay on one line (ellipsis instead of wrapping), and a
header row that starts with the title drops its actions to the line below instead of
squeezing (or overlapping) the title. */
@layer components {
@media (max-width: 639px) {
main :is(h1, h2) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
main
.flex:not(.flex-col):has(
> :is(h1, h2):first-child,
> div:first-child > :is(h1, h2):first-child
) {
flex-wrap: wrap;
}
main .flex:not(.flex-col) > :is(h1, h2):first-child,
main .flex:not(.flex-col) > div:first-child:has(> :is(h1, h2):first-child) {
/* important: a `flex-1` (basis 0) on the title would otherwise squeeze it to nothing */
flex-basis: 100% !important;
min-width: 0;
}
}
}
/* Ensure icon clicks always bubble to the parent button */

View file

@ -0,0 +1,29 @@
"use client";
import type { ReactNode } from "react";
import { cn } from "@/shared/utils/cn";
interface ActionBarProps {
children: ReactNode;
/** Alignment from `sm` up; on phones the buttons always fill the line. */
align?: "start" | "end";
className?: string;
}
/**
* Row of action buttons. On narrow screens the buttons grow to fill each line and
* wrap; from `sm` up the bar keeps its natural width.
*/
export default function ActionBar({ children, align = "end", className }: ActionBarProps) {
return (
<div
className={cn(
"flex w-full flex-wrap items-center gap-2 sm:w-auto sm:shrink-0 [&>*]:grow [&>*]:justify-center sm:[&>*]:grow-0",
align === "end" ? "sm:justify-end" : "sm:justify-start",
className
)}
>
{children}
</div>
);
}

View file

@ -0,0 +1,29 @@
"use client";
import type { CSSProperties, ReactNode } from "react";
import { cn } from "@/shared/utils/cn";
interface AutoGridProps {
children: ReactNode;
/** Minimum card width from `sm` up; columns fill the row with equal widths. */
min?: string;
className?: string;
}
/**
* Symmetric card grid: one column on phones, then as many equal columns as fit
* (`repeat(auto-fill, minmax(min, 1fr))`); cards in a row share the same height.
*/
export default function AutoGrid({ children, min = "260px", className }: AutoGridProps) {
return (
<div
className={cn(
"grid grid-cols-1 gap-3 sm:[grid-template-columns:repeat(auto-fill,minmax(var(--auto-grid-min),1fr))]",
className
)}
style={{ "--auto-grid-min": min } as CSSProperties}
>
{children}
</div>
);
}

View file

@ -0,0 +1,36 @@
import type { ReactNode } from "react";
import { cn } from "@/shared/utils/cn";
interface CapabilityBadgeProps {
/** Material Symbols name shown before the label (replaces the old emoji prefixes). */
icon?: string;
/** Tone classes, e.g. "bg-pink-500/15 text-pink-400". */
className?: string;
title?: string;
children: ReactNode;
}
/** Small pill describing a model capability (vision, images, audio…). */
export default function CapabilityBadge({
icon,
className,
title,
children,
}: CapabilityBadgeProps) {
return (
<span
title={title}
className={cn(
"inline-flex items-center gap-0.5 whitespace-nowrap rounded-full px-1.5 py-0.5 text-[10px] font-medium leading-none",
className
)}
>
{icon && (
<span className="material-symbols-outlined text-[12px] leading-none" aria-hidden="true">
{icon}
</span>
)}
{children}
</span>
);
}

View file

@ -0,0 +1,65 @@
"use client";
import { cn } from "@/shared/utils/cn";
export interface FilterPillOption {
value: string;
label: string;
icon?: string;
count?: number;
}
interface FilterPillsProps {
options: FilterPillOption[];
value: string;
onChange: (value: string) => void;
className?: string;
"aria-label"?: string;
}
/**
* Filter chips that wrap instead of overflowing. On narrow screens each line is
* stretched to the full width (pills grow), from `sm` up they keep their natural size.
*/
export default function FilterPills({
options,
value,
onChange,
className,
"aria-label": ariaLabel,
}: FilterPillsProps) {
return (
<div
role="tablist"
aria-label={ariaLabel}
className={cn("flex w-full flex-wrap items-center gap-1.5 sm:w-auto", className)}
>
{options.map((opt) => {
const active = value === opt.value;
return (
<button
key={opt.value}
type="button"
role="tab"
aria-selected={active}
onClick={() => onChange(opt.value)}
className={cn(
"inline-flex grow items-center justify-center gap-1 whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-medium transition-colors sm:grow-0",
active ? "bg-primary text-white" : "bg-muted/60 text-text-muted hover:bg-muted"
)}
>
{opt.icon && (
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
{opt.icon}
</span>
)}
{opt.label}
{opt.count !== undefined && (
<span className={active ? "text-white/80" : "text-text-muted/70"}>{opt.count}</span>
)}
</button>
);
})}
</div>
);
}

View file

@ -7,11 +7,13 @@ import { cn } from "@/shared/utils/cn";
type SpinnerSize = "sm" | "md" | "lg" | "xl";
type LoadingType = "spinner" | "page" | "skeleton" | "card";
// Box and glyph share one size so the icon spins around its own centre (a 24px glyph in a
// 16px box rotated around the box centre and wobbled).
const spinnerSizes: Record<SpinnerSize, string> = {
sm: "size-4",
md: "size-6",
lg: "size-8",
xl: "size-12",
sm: "size-4 text-[16px]",
md: "size-6 text-[24px]",
lg: "size-8 text-[32px]",
xl: "size-12 text-[48px]",
};
interface SpinnerProps {
@ -52,7 +54,7 @@ export function Spinner({ size = "md", className, label }: SpinnerProps) {
<span
aria-hidden="true"
className={cn(
"material-symbols-outlined text-primary animate-spin motion-reduce:animate-none",
"material-symbols-outlined inline-flex items-center justify-center leading-none text-primary animate-spin motion-reduce:animate-none",
spinnerSizes[size]
)}
>

View file

@ -0,0 +1,24 @@
"use client";
import type { ReactNode } from "react";
import { cn } from "@/shared/utils/cn";
/** Grid of provider cards: one column on phones, two from 480px, then 3 and 4. */
export default function ProviderCardGrid({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"grid grid-cols-1 gap-3 min-[480px]:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4",
className
)}
>
{children}
</div>
);
}

View file

@ -0,0 +1,58 @@
"use client";
import { cn } from "@/shared/utils/cn";
interface ReorderControlProps {
onMoveUp?: () => void;
onMoveDown?: () => void;
isFirst?: boolean;
isLast?: boolean;
upLabel?: string;
downLabel?: string;
className?: string;
}
/** Compact up/down pair used to change an item's priority in a list. */
export default function ReorderControl({
onMoveUp,
onMoveDown,
isFirst = false,
isLast = false,
upLabel = "Move up",
downLabel = "Move down",
className,
}: ReorderControlProps) {
const btn = (disabled: boolean) =>
cn(
"flex size-5 items-center justify-center rounded",
disabled
? "cursor-not-allowed text-text-muted/30"
: "text-text-muted hover:bg-sidebar hover:text-primary"
);
return (
<div className={cn("flex shrink-0 flex-col", className)}>
<button
type="button"
onClick={onMoveUp}
disabled={isFirst}
aria-label={upLabel}
className={btn(isFirst)}
>
<span className="material-symbols-outlined text-[18px] leading-none">
keyboard_arrow_up
</span>
</button>
<button
type="button"
onClick={onMoveDown}
disabled={isLast}
aria-label={downLabel}
className={btn(isLast)}
>
<span className="material-symbols-outlined text-[18px] leading-none">
keyboard_arrow_down
</span>
</button>
</div>
);
}

View file

@ -36,7 +36,8 @@ export default function SegmentedControl({
role="tablist"
aria-label={ariaLabel}
className={cn(
"inline-flex items-center p-1 rounded-lg",
// Phones: full width, segments share the line and wrap; from sm up, natural width.
"flex w-full flex-wrap items-center p-1 rounded-lg sm:inline-flex sm:w-auto",
"bg-black/5 dark:bg-white/5",
className
)}
@ -49,12 +50,11 @@ export default function SegmentedControl({
tabIndex={value === option.value ? 0 : -1}
onClick={() => onChange(option.value)}
className={cn(
"px-4 rounded-md font-medium transition-all",
"grow px-3 rounded-md font-medium transition-all whitespace-nowrap flex items-center justify-center sm:grow-0 sm:px-4",
sizes[size],
value === option.value
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
: "text-text-muted hover:text-text-main",
option.icon && "flex items-center",
: "text-text-muted hover:text-text-main"
)}
>
{option.icon && (

View file

@ -18,6 +18,8 @@ interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>
selectClassName?: string;
/** Keep the placeholder selectable after a real value is chosen. */
placeholderDisabled?: boolean;
/** Label and select on one line, with a shorter select (settings rows, cards). */
compact?: boolean;
}
export default function Select({
@ -33,6 +35,7 @@ export default function Select({
className,
selectClassName,
placeholderDisabled = true,
compact = false,
id: externalId,
children,
...props
@ -45,9 +48,19 @@ export default function Select({
const describedBy = [errorId, hintId].filter(Boolean).join(" ") || undefined;
return (
<div className={cn("flex flex-col gap-1.5", className)}>
<div
className={cn(
compact
? "flex flex-wrap items-center justify-between gap-x-3 gap-y-1"
: "flex flex-col gap-1.5",
className
)}
>
{label && (
<label htmlFor={selectId} className="text-sm font-medium text-text-main">
<label
htmlFor={selectId}
className={cn("font-medium text-text-main", compact ? "text-xs" : "text-sm")}
>
{label}
{required && (
<span className="text-red-500 ml-1" aria-hidden="true">
@ -56,7 +69,7 @@ export default function Select({
)}
</label>
)}
<div className="relative">
<div className={cn("relative", compact && "min-w-0 flex-1 sm:flex-none")}>
<select
id={selectId}
value={value}
@ -67,7 +80,9 @@ export default function Select({
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
className={cn(
"w-full py-2 px-3 pe-10 text-sm text-text-main",
compact
? "w-full py-1.5 ps-2.5 pe-8 text-sm text-text-main"
: "w-full py-2 px-3 pe-10 text-sm text-text-main",
"bg-surface border border-black/10 dark:border-white/10 rounded-control appearance-none",
"focus:ring-1 focus:ring-accent/30 focus:border-accent/50 focus:outline-none",
"transition-all disabled:opacity-50 disabled:cursor-not-allowed",

View file

@ -29,7 +29,7 @@ export default function Toggle({
xs: {
track: "w-6 h-3",
thumb: "size-[8px]",
translate: "translate-x-3.5",
translate: "translate-x-3",
},
sm: {
track: "w-8 h-4",
@ -71,7 +71,7 @@ export default function Toggle({
disabled={disabled}
onClick={handleClick}
className={cn(
"relative inline-flex shrink-0 cursor-pointer rounded-full",
"relative inline-flex shrink-0 cursor-pointer items-center rounded-full",
"transition-colors duration-200 ease-in-out",
"border shadow-inner",
"focus:outline-none focus:ring-1 focus:ring-accent/30",
@ -86,8 +86,7 @@ export default function Toggle({
"pointer-events-none inline-block rounded-full bg-white shadow-sm",
"transform transition duration-200 ease-in-out",
checked ? sizes[size].translate : "translate-x-0.5",
sizes[size].thumb,
"mt-0.5"
sizes[size].thumb
)}
/>
</button>

View file

@ -33,6 +33,12 @@ export { default as KiroSocialOAuthModal } from "./KiroSocialOAuthModal";
export { default as CursorAuthModal } from "./CursorAuthModal";
export { default as TraeAuthModal } from "./TraeAuthModal";
export { default as SegmentedControl } from "./SegmentedControl";
export { default as FilterPills } from "./FilterPills";
export { default as ActionBar } from "./ActionBar";
export { default as ReorderControl } from "./ReorderControl";
export { default as ProviderCardGrid } from "./ProviderCardGrid";
export { default as CapabilityBadge } from "./CapabilityBadge";
export { default as AutoGrid } from "./AutoGrid";
export { default as Breadcrumbs } from "./Breadcrumbs";
export { default as EmptyState } from "./EmptyState";
export { default as NotificationToast } from "./NotificationToast";

View file

@ -38,8 +38,12 @@ const rowFile = path.join(
test("ConnectionRow action-icon wrapper must use an RTL-mirroring (logical) spacing utility, not a physical one", () => {
const source = readFileSync(rowFile, "utf8");
// The action-icon wrapper is the div that directly holds the retest button
// (the first icon of the cluster); match it by that anchor rather than by its
// exact class list, so layout changes keep the RTL check meaningful. The tip's
// ConnectionTestButton (send-a-test-message) may sit first in the same cluster.
const match = source.match(
/<div className="flex gap-1 ([^"]*)transition-opacity">/
/<div className="([^"]*)">\s*(?:<ConnectionTestButton\b[^>]*\/>\s*)?<Button\b[^>]*?icon="refresh"/
);
assert.ok(match, "expected to find the action-icon wrapper div in ConnectionRow.tsx");

View file

@ -0,0 +1,157 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(
new URL(
"../../src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx",
import.meta.url
),
"utf8"
);
test("provider connection actions fill the row width and wrap on mobile", () => {
assert.match(
source,
/className="flex w-full flex-wrap items-center justify-between gap-0.5 sm:gap-1 xl:justify-end \[&>button\]:grow-0"/,
"on narrow screens the action icons must spread across the full row width and wrap instead of scrolling"
);
assert.match(
source,
/className="contents"/,
"icon-only actions must join the same flex line so they are spread with the other actions"
);
assert.match(
source,
/className="inline-flex size-7 items-center justify-center/,
"icon-only actions must use the same square touch target"
);
assert.match(
source,
/className="!size-7 !p-0 sm:!w-auto sm:!px-2 text-xs"/,
"labeled actions must collapse to the same icon-only square on narrow rows"
);
// PR #15532 review: the collapse is phone-only — from `sm` up the labeled actions
// keep their visible text (Retest, Token, Apply/Export Codex/Claude auth).
for (const label of [
't("retest")',
't("tokenShort")',
"applyCodexAuthLabel",
"exportCodexAuthLabel",
"applyClaudeAuthLabel",
"exportClaudeAuthLabel",
]) {
assert.ok(
source.includes(`<span className="hidden sm:inline">{${label}}</span>`),
`the ${label} action must show its text label from the sm breakpoint up`
);
}
assert.doesNotMatch(
source,
/className="!size-7 !p-0 text-xs[^"]*"/,
"no labeled action may stay icon-only on desktop widths"
);
assert.match(
source,
/className="basis-full min-w-0 xl:basis-auto"/,
"narrow screens must give the action bar the full row width"
);
assert.doesNotMatch(
source,
/w-max min-w-full|overflow-x-auto xl:basis-auto/,
"the action bar must not fall back to a horizontally scrolling single row"
);
});
const read = (rel: string) => readFileSync(new URL(`../../${rel}`, import.meta.url), "utf8");
const providerComponents = "src/app/(dashboard)/dashboard/providers/[id]/components";
test("connections toolbar and bulk actions use the shared full-width ActionBar", () => {
// Codex keeps its start-aligned bar (#16248); every provider uses the shared ActionBar.
assert.match(
read(`${providerComponents}/ConnectionsHeaderToolbar.tsx`),
/<ActionBar align=\{providerId === "codex" \? "start" : "end"\}>/
);
assert.match(read(`${providerComponents}/ConnectionsListPanel.tsx`), /<ActionBar>/);
const actionBar = read("src/shared/components/ActionBar.tsx");
assert.match(
actionBar,
/flex w-full flex-wrap items-center gap-2 sm:w-auto sm:shrink-0 \[&>\*\]:grow \[&>\*\]:justify-center sm:\[&>\*\]:grow-0/,
"on narrow screens the bar buttons must grow to fill each line and wrap"
);
});
test("connection filters use the shared FilterPills that fill the line on phones", () => {
assert.match(read(`${providerComponents}/ConnectionsListPanel.tsx`), /<FilterPills/);
const pills = read("src/shared/components/FilterPills.tsx");
assert.match(pills, /flex w-full flex-wrap items-center gap-1\.5 sm:w-auto/);
assert.match(pills, /inline-flex grow items-center justify-center/);
});
test("connection rows use the compact shared ReorderControl and hide separators on phones", () => {
assert.match(source, /<ReorderControl/);
assert.doesNotMatch(source, /<span className="text-text-muted\/30 select-none">\|<\/span>/);
assert.match(
read("src/shared/components/ReorderControl.tsx"),
/flex size-5 items-center justify-center/
);
});
test("icon size utilities win over the Material Symbols base size", () => {
const globals = read("src/app/globals.css");
assert.match(globals, /@import "material-symbols\/outlined\.css" layer\(base\);/);
assert.match(globals, /@layer base \{\s*:where\(\.material-symbols-outlined\) \{/);
});
test("provider lists use the shared one-column-on-phones card grid", () => {
const page = read("src/app/(dashboard)/dashboard/providers/page.tsx");
assert.match(page, /<ProviderCardGrid>/);
assert.doesNotMatch(
page,
/<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">/
);
assert.match(
read("src/shared/components/ProviderCardGrid.tsx"),
/grid grid-cols-1 gap-3 min-\[480px\]:grid-cols-2/
);
});
test("no bare pipe separators are left visible on phones in the connection row", () => {
const quotaToggle = read(`${providerComponents}/ProviderQuotaVisibilityToggle.tsx`);
assert.doesNotMatch(quotaToggle, /<span className="text-text-muted\/30 select-none">\|<\/span>/);
});
test("custom model add and edit forms share the two-column phone grid", () => {
const custom = read(`${providerComponents}/CustomModelsSection.tsx`);
assert.match(custom, /const FORMAT_FIELDS_GRID = "grid grid-cols-2 items-end gap-3/);
assert.equal(custom.match(/className=\{FORMAT_FIELDS_GRID\}/g)?.length, 2);
assert.equal(custom.match(/className=\{ENDPOINTS_GRID\}/g)?.length, 2);
assert.doesNotMatch(custom, /overflow-x-auto overflow-y-visible/);
});
test("toggle thumb is vertically centred in its track", () => {
const toggle = read("src/shared/components/Toggle.tsx");
assert.match(toggle, /"relative inline-flex shrink-0 cursor-pointer items-center rounded-full"/);
assert.doesNotMatch(toggle, /"mt-0\.5"/);
});
test("provider models use the symmetric AutoGrid", () => {
assert.match(read(`${providerComponents}/ProviderModelsSection.tsx`), /<AutoGrid>/);
assert.match(
read("src/shared/components/AutoGrid.tsx"),
/repeat\(auto-fill,minmax\(var\(--auto-grid-min\),1fr\)\)/
);
assert.doesNotMatch(read(`${providerComponents}/ModelRow.tsx`), /max-w-md/);
});
test("model card: icon + full name on top, copy pinned top-right, details below", () => {
const row = read(`${providerComponents}/ModelRow.tsx`);
assert.match(row, /relative flex h-full w-full min-w-0 flex-col gap-1\.5/);
assert.match(row, /<code className="min-w-0 break-all pt-px font-mono/);
assert.match(row, /<div className="flex min-w-0 items-center gap-2 pl-6">/);
assert.match(
read(`${providerComponents}/ModelCompatPopover.tsx`),
/<span className="hidden sm:inline">\{t\("compatButtonLabel"\)\}<\/span>/
);
assert.match(row, /absolute right-2 top-2 rounded p-0\.5/);
});

View file

@ -115,7 +115,9 @@ describe("ConnectionRow Codex account pool", () => {
const row = container.firstElementChild as HTMLElement;
expect(row.classList.contains("flex-col")).toBe(true);
expect(row.classList.contains("items-stretch")).toBe(true);
expect(row.children[1].classList.contains("flex-wrap")).toBe(true);
// #15532: the actions live in a full-width wrapper; the wrapping flex bar is its child.
const actionBar = row.children[1].firstElementChild as HTMLElement;
expect(actionBar.classList.contains("flex-wrap")).toBe(true);
act(() => {
(container.querySelector("button[title='edit']") as HTMLButtonElement).click();

View file

@ -22,6 +22,7 @@ vi.mock("next-intl", () => ({
vi.mock("@/shared/components", () => ({
Badge: ({ children, title }: any) => <span title={title}>{children}</span>,
CapabilityBadge: ({ children, title }: any) => <span title={title}>{children}</span>,
Button: ({ children, onClick }: any) => <button onClick={onClick}>{children}</button>,
}));