fix: make loading spinners actually spin across the dashboard

Many loading states rendered bare "Loading…" text with no spinner
element, and a couple rendered an unstyled `loading-spinner` div with no
matching CSS (invisible). The global spin animation was never broken —
it rotates in Chromium and WebKit — so prior transform-box edits chased
a non-bug, validated only by a string-matching CSS test.

Add a shared <LoadingSpinner> (self-contained animated SVG, no
lucide-react import so it survives partial test mocks) and adopt it
across ~47 loading placeholders so every loading state shows a
consistent animated spinner. Includes a component test that asserts the
svg carries the animate-spin utility.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-21 03:53:43 -07:00
parent 0a04feec93
commit 3b61ac3d23
38 changed files with 185 additions and 49 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix loading spinners that didn't spin across the dashboard. Many loading states (Settings, task tabs, agents, documents, plugins, model pickers, command center, and more) rendered bare "Loading…" text with no spinner — and a couple rendered an unstyled `loading-spinner` div that never showed anything. Added a shared `<LoadingSpinner>` component (self-contained animated SVG, no `lucide-react` dependency so it survives partial test mocks) and adopted it across ~45 loading placeholders so every loading state now shows a consistent animated spinner.

View File

@@ -18,6 +18,7 @@ import type { Agent } from "../api";
import type { AgentLogEntry, Task, Message, ParticipantType, AgentPermissionPolicy, AgentPermissionPolicyRules } from "@fusion/core";
import { getErrorMessage, isEphemeralAgent } from "@fusion/core";
import { AgentLogViewer } from "./AgentLogViewer";
import { LoadingSpinner } from "./LoadingSpinner";
import { AgentReflectionsTab } from "./AgentReflectionsTab";
import { getAgentHealthStatus } from "../utils/agentHealth";
import type { AgentHealthStatus } from "../utils/agentHealth";
@@ -4682,7 +4683,7 @@ function ConfigTab({
<div className="config-field">
<label htmlFor="agent-runtime-hint">{t("agents.runtimeLabel", "Runtime")}</label>
{runtimesLoading ? (
<span className="config-hint">{t("agents.loadingRuntimes", "Loading runtimes…")}</span>
<span className="config-hint"><LoadingSpinner label={t("agents.loadingRuntimes", "Loading runtimes…")} /></span>
) : (
<select
id="agent-runtime-hint"

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { FileCode, ChevronDown, ChevronRight, AlertCircle, GitCommit } from "lucide-react";
import type { MergeDetails } from "@fusion/core";
import { LoadingSpinner } from "./LoadingSpinner";
import { fetchCommitDiff } from "../api";
import { getErrorMessage } from "@fusion/core";
import { highlightDiff } from "../utils/highlightDiff";
@@ -137,8 +138,7 @@ export function CommitDiffTab({ commitSha, mergeDetails }: CommitDiffTabProps) {
return (
<div className="detail-section">
<div className="task-changes-state task-changes-state--loading">
<div className="loading-spinner" />
<span>{t("commitDiff.loading", "Loading commit diff...")}</span>
<LoadingSpinner label={t("commitDiff.loading", "Loading commit diff...")} />
</div>
</div>
);

View File

@@ -4,6 +4,7 @@ import { createPortal } from "react-dom";
import { fetchAgents } from "../api";
import type { Agent } from "@fusion/core";
import { AgentAvatar } from "./AgentAvatar";
import { LoadingSpinner } from "./LoadingSpinner";
import "./CreateRoomModal.css";
export interface RoomDraft {
@@ -195,7 +196,7 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
<div className="create-room-modal-member-list" data-testid="create-room-member-list">
{loadingAgents ? (
<div className="create-room-modal-empty">{t("createRoom.loadingAgents", "Loading agents...")}</div>
<div className="create-room-modal-empty"><LoadingSpinner label={t("createRoom.loadingAgents", "Loading agents...")} /></div>
) : filteredAgents.length === 0 ? (
<div className="create-room-modal-empty">
{agents.length === 0 ? t("createRoom.noAgents", "No agents in this project yet.") : t("createRoom.noMatch", "No agents match your search.")}

View File

@@ -11,6 +11,7 @@ import { useDocuments } from "../hooks/useDocuments";
import { useProjectMarkdownFiles } from "../hooks/useProjectMarkdownFiles";
import { useSelectionComment } from "../hooks/useSelectionComment";
import { SelectionCommentPopover } from "./SelectionCommentPopover";
import { LoadingSpinner } from "./LoadingSpinner";
const MOBILE_BREAKPOINT = 768;
@@ -482,7 +483,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
) : activeTab === "project" ? (
projectFilesLoading && projectFiles.length === 0 ? (
<div className="documents-view-loading">
<p>{t("documents.loadingProjectFiles", "Loading project markdown files…")}</p>
<p><LoadingSpinner label={t("documents.loadingProjectFiles", "Loading project markdown files…")} /></p>
</div>
) : filteredProjectFiles.length === 0 ? (
<div className="documents-view-empty">
@@ -555,7 +556,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
</button>
</div>
{fileLoading ? (
<p className="documents-content-state">{t("documents.loadingFileContent", "Loading file content…")}</p>
<p className="documents-content-state"><LoadingSpinner label={t("documents.loadingFileContent", "Loading file content…")} /></p>
) : fileError ? (
<p className="documents-content-state documents-content-state--error">{fileError}</p>
) : renderProjectMarkdown ? (
@@ -576,7 +577,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
)
) : documentsLoading && documents.length === 0 ? (
<div className="documents-view-loading">
<p>{t("documents.loadingTaskDocuments", "Loading task documents…")}</p>
<p><LoadingSpinner label={t("documents.loadingTaskDocuments", "Loading task documents…")} /></p>
</div>
) : groupedDocuments.length === 0 ? (
<div className="documents-view-empty">

View File

@@ -4,6 +4,7 @@ import { ExternalLink, RefreshCw, Settings } from "lucide-react";
import { fetchSettings } from "../api";
import { useEvals } from "../hooks/useEvals";
import type { SectionId } from "./SettingsModal";
import { LoadingSpinner } from "./LoadingSpinner";
import "./EvalsView.css";
interface EvalsViewProps {
@@ -74,7 +75,7 @@ export function EvalsView({ projectId, onOpenSettings, onOpenTaskDetail }: Evals
<button className="btn btn-icon" type="button" onClick={() => void refresh()} aria-label={t("evals.refreshAria", "Refresh evals")}><RefreshCw size={16} /></button>
</div>
{loading && <p className="evals-state" data-testid="evals-loading">{t("evals.loading", "Loading evals…")}</p>}
{loading && <p className="evals-state" data-testid="evals-loading"><LoadingSpinner label={t("evals.loading", "Loading evals…")} /></p>}
{error && <p className="evals-state evals-state--error">{error}</p>}
{!loading && !error && !hasResults && (
<p className="evals-state">{t("evals.empty", "No evals yet. Scheduled evals review tasks completed since the last run.")}</p>

View File

@@ -11,6 +11,7 @@ import { AlertTriangle, Clock, Folder, Pause, Play, Zap } from "lucide-react";
import { computeBlockerFanoutMap } from "../hooks/useBlockerFanout";
import { useExecutorStats } from "../hooks/useExecutorStats";
import { isLikelyTabSuspensionError } from "../hooks/visibilitySuspension";
import { LoadingSpinner } from "./LoadingSpinner";
import type { ExecutorState, AiSessionSummary } from "../api";
import { BackgroundTasksIndicator } from "./BackgroundTasksIndicator";
@@ -149,7 +150,7 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
if (loading && stats.runningTaskCount === 0) {
return (
<div className="executor-status-bar executor-status-bar--loading" role="status" aria-label={t("executor.status", "Executor status")}>
<span className="executor-status-bar__loading-text">{t("executor.loading", "Loading...")}</span>
<LoadingSpinner className="executor-status-bar__loading-text" label={t("executor.loading", "Loading...")} />
</div>
);
}

View File

@@ -12,6 +12,7 @@ import { useNodes } from "../hooks/useNodes";
import { ModelSelectionModal } from "./ModelSelectionModal";
import { NodeHealthDot } from "./NodeHealthDot";
import { DuplicateWarningModal } from "./DuplicateWarningModal";
import { LoadingSpinner } from "./LoadingSpinner";
import { applyPresetToSelection } from "../utils/modelPresets";
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
import { WorkflowSelector } from "./WorkflowSelector";
@@ -1042,7 +1043,7 @@ export function InlineCreateCard({
{showAgentPicker && (
<div className="dep-dropdown agent-picker-dropdown" onMouseDown={(e) => e.preventDefault()}>
<div className="dep-dropdown-search-header">{t("inline.selectAgent", "Select agent")}</div>
{agentsLoading && <div className="dep-dropdown-empty">{t("inline.loadingAgents", "Loading agents...")}</div>}
{agentsLoading && <div className="dep-dropdown-empty"><LoadingSpinner label={t("inline.loadingAgents", "Loading agents...")} /></div>}
{!agentsLoading && agents.map((a) => (
<div
key={a.id}

View File

@@ -0,0 +1,11 @@
.loading-spinner {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
color: var(--text-muted);
}
/* Keep the label on the spinner's baseline regardless of the host container. */
.loading-spinner__label {
line-height: 1.2;
}

View File

@@ -0,0 +1,47 @@
import "./LoadingSpinner.css";
interface LoadingSpinnerProps {
/** Already-translated text shown beside the spinner (omit for icon-only). */
label?: string;
/** Icon size in px. Defaults to 14 to match inline loading text. */
size?: number;
/** Extra class on the wrapper, e.g. to slot into a section's layout. */
className?: string;
}
/**
* Shared loading indicator: an animated spinner plus optional label.
*
* Most loading states across the dashboard historically rendered bare
* "Loading…" text with no spinner, so nothing actually spun. Drop this inside
* the existing loading container (keeping its class for layout) and pass the
* translated label to get a consistent animated spinner everywhere.
*
* The icon is a self-contained inline SVG (visually identical to lucide's
* Loader2) rather than a `lucide-react` import, so it renders in component
* tests that stub `lucide-react` with a partial mock. It spins via the global
* `.animate-spin` keyframe in styles.css.
*/
export const LoadingSpinner = ({ label, size = 14, className }: LoadingSpinnerProps) => (
<span
className={className ? `loading-spinner ${className}` : "loading-spinner"}
role="status"
aria-live="polite"
>
<svg
className="animate-spin"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
{label ? <span className="loading-spinner__label">{label}</span> : null}
</span>
);

View File

@@ -6,6 +6,7 @@ import type { ModelInfo } from "../api";
import { applyPresetToSelection } from "../utils/modelPresets";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { Brain, X } from "lucide-react";
import { LoadingSpinner } from "./LoadingSpinner";
const PRESET_OPTION_SEPARATOR = "──────────";
@@ -175,7 +176,7 @@ export function ModelSelectionModal({
{modelsLoading ? (
<div className="planning-loading">
<div className="detail-section">
<p className="text-muted">{t("modelSelection.loading", "Loading models…")}</p>
<p className="text-muted"><LoadingSpinner label={t("modelSelection.loading", "Loading models…")} /></p>
</div>
</div>
) : modelsError ? (

View File

@@ -14,6 +14,7 @@ import { useFavorites } from "../hooks/useFavorites";
import { useModelsCache } from "../hooks/useModelsCache";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ProviderIcon } from "./ProviderIcon";
import { LoadingSpinner } from "./LoadingSpinner";
interface ModelSelectorTabProps {
task: Task | TaskDetail;
@@ -376,7 +377,7 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings }: Mo
</p>
{modelsLoading ? (
<div className="model-selector-loading">{t("models.states.loading", "Loading available models…")}</div>
<div className="model-selector-loading"><LoadingSpinner label={t("models.states.loading", "Loading available models…")} /></div>
) : availableModels.length === 0 ? (
<div className="model-selector-empty">
{t("models.emptyStates.noModels", "No models available. Configure authentication in Settings to enable model selection.")}

View File

@@ -6,6 +6,7 @@ import type { Agent, AgentCapability, ModelInfo, AgentGenerationSpec, PluginRunt
import { createAgent, fetchAgents, fetchModels } from "../api";
import * as apiModule from "../api";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { LoadingSpinner } from "./LoadingSpinner";
import { ProviderIcon } from "./ProviderIcon";
import { AgentGenerationModal } from "./AgentGenerationModal";
import { AGENT_PRESETS, type AgentPreset } from "./agent-presets";
@@ -353,7 +354,7 @@ export function NewAgentDialog({
<div className="agent-dialog-field">
<label>{t("agents.model", "Model")}</label>
{modelsLoading ? (
<div className="agent-dialog-loading">{t("agents.loadingModels", "Loading models…")}</div>
<div className="agent-dialog-loading"><LoadingSpinner label={t("agents.loadingModels", "Loading models…")} /></div>
) : (
<CustomModelDropdown
id="agent-model"
@@ -373,7 +374,7 @@ export function NewAgentDialog({
<div className="agent-dialog-field">
<label htmlFor="agent-runtime-hint">{t("agents.runtime", "Runtime")}</label>
{runtimesLoading ? (
<div className="agent-dialog-loading">{t("agents.loadingRuntimes", "Loading runtimes…")}</div>
<div className="agent-dialog-loading"><LoadingSpinner label={t("agents.loadingRuntimes", "Loading runtimes…")} /></div>
) : (
<select
id="agent-runtime-hint"

View File

@@ -8,6 +8,7 @@ import { uploadAttachment } from "../api";
import { Bot } from "lucide-react";
import { useSetupReadiness } from "../hooks/useSetupReadiness";
import { SetupWarningBanner } from "./SetupWarningBanner";
import { LoadingSpinner } from "./LoadingSpinner";
import { TaskForm, type BranchSelectionMode, type PendingImage } from "./TaskForm";
import { REPO_OVERRIDE_RE } from "./githubTracking";
import { useConfirm } from "../hooks/useConfirm";
@@ -424,7 +425,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
{showAgentPicker && (
<div className="dep-dropdown agent-picker-dropdown" onMouseDown={(e) => e.preventDefault()}>
<div className="dep-dropdown-search-header">{t("newTaskModal.selectAgent", "Select agent")}</div>
{agentsLoading && <div className="dep-dropdown-empty">{t("newTaskModal.loadingAgents", "Loading agents...")}</div>}
{agentsLoading && <div className="dep-dropdown-empty"><LoadingSpinner label={t("newTaskModal.loadingAgents", "Loading agents...")} /></div>}
{!agentsLoading && agents.map((a) => (
<div
key={a.id}

View File

@@ -30,6 +30,7 @@ import {
} from "lucide-react";
import { fetchPiSettings, updatePiSettings, installPiPackage, reinstallFusionPiPackage, fetchPiExtensions, updatePiExtensions, type PiSettings, type PiExtensionEntry } from "../api";
import type { ToastType } from "../hooks/useToast";
import { LoadingSpinner } from "./LoadingSpinner";
interface PiExtensionsManagerProps {
addToast: (message: string, type?: ToastType) => void;
@@ -248,7 +249,7 @@ export function PiExtensionsManager({ addToast, projectId }: PiExtensionsManager
</div>
{loading ? (
<div className="loading-state">{t("piManager.loading", "Loading Pi settings…")}</div>
<div className="loading-state"><LoadingSpinner label={t("piManager.loading", "Loading Pi settings…")} /></div>
) : !settings ? (
<div className="empty-state">
<Package size={32} className="text-muted" />
@@ -434,7 +435,7 @@ export function PiExtensionsManager({ addToast, projectId }: PiExtensionsManager
</p>
{extensionsLoading ? (
<div className="loading-state">{t("piManager.loadingExtensions", "Loading extensions…")}</div>
<div className="loading-state"><LoadingSpinner label={t("piManager.loadingExtensions", "Loading extensions…")} /></div>
) : extensions.length === 0 ? (
<div className="empty-state">
<Package size={32} className="text-muted" />

View File

@@ -16,6 +16,7 @@ import { useTranslation } from "react-i18next";
import { Package, Settings, Trash2, Plus, X, RefreshCw, RotateCcw, ExternalLink, Shield } from "lucide-react";
import { fetchPlugins, fetchPluginRegistry, installPlugin, enablePlugin, disablePlugin, uninstallPlugin, fetchPluginSettings, updatePluginSettings, reloadPlugin, fetchPluginSetupStatus, installPluginSetup, updatePlugin, rescanPlugin } from "../api";
import { DirectoryPicker } from "./DirectoryPicker";
import { LoadingSpinner } from "./LoadingSpinner";
import type { PluginInstallation, PluginState, PluginSettingSchema } from "@fusion/core";
import type { PluginSetupStatusResponse, RegistryPluginEntry } from "../api";
import type { ToastType } from "../hooks/useToast";
@@ -726,7 +727,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
<div className="plugin-detail-card">
<h5 className="plugin-detail-section-heading">{t("plugins.settings", "Settings")}</h5>
{settingsLoading ? (
<p className="text-muted">{t("plugins.loading", "Loading...")}</p>
<p className="text-muted"><LoadingSpinner label={t("plugins.loading", "Loading...")} /></p>
) : (() => {
const effectiveSettingsSchema = resolveSettingsSchema(selectedPlugin);
@@ -1198,7 +1199,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
)}
{loading ? (
<div className="settings-empty-state">{t("plugins.loadingPlugins", "Loading plugins...")}</div>
<div className="settings-empty-state"><LoadingSpinner label={t("plugins.loadingPlugins", "Loading plugins...")} /></div>
) : (
<>
{installedPlugins.length === 0 ? (

View File

@@ -19,6 +19,7 @@ import { ChevronDown, Eye, EyeOff, Hash, MessageSquare, Paperclip, Pencil, Plus,
import { attachmentBaseUrlForRoom, type Agent, type ModelInfo } from "../api";
import type { DiscoveredSkill } from "@fusion/dashboard";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { LoadingSpinner } from "./LoadingSpinner";
import { ChatQuestionResponse } from "./ChatQuestionResponse";
import { ProviderIcon } from "./ProviderIcon";
import { AgentMentionPopup } from "./AgentMentionPopup";
@@ -3205,7 +3206,7 @@ export function QuickChatFAB({
<div className="quick-chat-panel-messages" ref={messagesRef} data-testid="quick-chat-messages" onScroll={updateScrollState}>
{sessionsLoading ? (
<div className="quick-chat-panel-empty">{t("chat.loadingConversation", "Loading conversation…")}</div>
<div className="quick-chat-panel-empty"><LoadingSpinner label={t("chat.loadingConversation", "Loading conversation…")} /></div>
) : !roomThreadActive && isStreaming ? (
<>
{displayedMessages.map((message: ChatMessageInfo, index) => (
@@ -3264,7 +3265,7 @@ export function QuickChatFAB({
</div>
</>
) : roomThreadActive ? roomsState.messagesLoading ? (
<div className="quick-chat-panel-empty">{t("chat.loadingConversation", "Loading conversation…")}</div>
<div className="quick-chat-panel-empty"><LoadingSpinner label={t("chat.loadingConversation", "Loading conversation…")} /></div>
) : displayedMessages.length === 0 && !helpMessageVisible ? (
<div className="quick-chat-panel-empty">{t("chat.noMessagesYet", "No messages yet. Start the conversation!")}</div>
) : (
@@ -3290,7 +3291,7 @@ export function QuickChatFAB({
)}
</>
) : messagesLoading ? (
<div className="quick-chat-panel-empty">{t("chat.loadingConversation", "Loading conversation…")}</div>
<div className="quick-chat-panel-empty"><LoadingSpinner label={t("chat.loadingConversation", "Loading conversation…")} /></div>
) : displayedMessages.length === 0 && !streamingText && !streamingThinking && !isStreaming && !helpMessageVisible ? (
<div className="quick-chat-panel-empty">{t("chat.noMessagesYet", "No messages yet. Start the conversation!")}</div>
) : (
@@ -3510,7 +3511,7 @@ export function QuickChatFAB({
{showSkillMenu && (
<div className="chat-skill-menu" data-testid="quick-chat-skill-menu" role="listbox" aria-label={t("chat.skillSuggestions", "Skill suggestions")}>
{skillsLoading ? (
<div className="chat-skill-menu-empty">{t("chat.loadingSkills", "Loading skills…")}</div>
<div className="chat-skill-menu-empty"><LoadingSpinner label={t("chat.loadingSkills", "Loading skills…")} /></div>
) : filteredSkills.length === 0 ? (
<div className="chat-skill-menu-empty">
{skillFilter ? t("chat.noSkillsFound", "No skills found") : t("chat.noSkillsAvailable", "No skills available")}

View File

@@ -10,6 +10,7 @@ import { checkDuplicateTasks, fetchModels, fetchSettings, refineText, getRefineE
import { DuplicateWarningModal } from "./DuplicateWarningModal";
import { Link, Paperclip, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp, ChevronRight, Bot, Server, Flag } from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { LoadingSpinner } from "./LoadingSpinner";
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
import { useNodes } from "../hooks/useNodes";
import { NodeHealthDot } from "./NodeHealthDot";
@@ -1992,7 +1993,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
}}
>
<div className="dep-dropdown-search-header">{t("tasks.selectAgent", "Select agent")}</div>
{agentsLoading && <div className="dep-dropdown-empty">{t("tasks.loadingAgents", "Loading agents...")}</div>}
{agentsLoading && <div className="dep-dropdown-empty"><LoadingSpinner label={t("tasks.loadingAgents", "Loading agents...")} /></div>}
{!agentsLoading && agents.map((a) => (
<div
key={a.id}

View File

@@ -7,6 +7,7 @@ import { fetchAuthStatus, fetchSettings } from "../api";
import { useResearch } from "../hooks/useResearch";
import type { ResearchProviderOption } from "../research-types";
import { ResearchTaskActionModal } from "./ResearchTaskActionModal";
import { LoadingSpinner } from "./LoadingSpinner";
import type { SectionId } from "./SettingsModal";
import "./ResearchView.css";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
@@ -355,7 +356,7 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
</aside>
<div className="research-view__reader card">
{loading && <p data-testid="research-state-loading">{t("research.loadingRuns", "Loading research runs…")}</p>}
{loading && <p data-testid="research-state-loading"><LoadingSpinner label={t("research.loadingRuns", "Loading research runs…")} /></p>}
{!loading && error && <p data-testid="research-state-error">{error}</p>}
{!loading && !error && runs.length === 0 && <p data-testid="research-state-empty">{t("research.noRunsYet", "No research runs yet")}</p>}
<div className="research-view__reader-content">

View File

@@ -38,6 +38,7 @@ import { AgentPermissionsSection } from "./settings/sections/AgentPermissionsSec
import { MemorySection } from "./settings/sections/MemorySection";
import { ResearchProjectSection } from "./settings/sections/ResearchProjectSection";
import { BackupsSection } from "./settings/sections/BackupsSection";
import { LoadingSpinner } from "./LoadingSpinner";
import { PluginsSection } from "./settings/sections/PluginsSection";
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
@@ -2910,7 +2911,7 @@ export function SettingsModal({
</button>
</div>
{loading ? (
<div className="settings-empty-state settings-loading">{t("settings.loading", "Loading…")}</div>
<div className="settings-empty-state settings-loading"><LoadingSpinner label={t("settings.loading", "Loading…")} /></div>
) : (
<div className="settings-layout">
{showMobileSectionPicker && (

View File

@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { ChevronDown, Download, Upload } from "lucide-react";
import "./SettingsSyncLog.css";
import { linkifyFilePaths } from "../utils/filePathLinkify";
import { LoadingSpinner } from "./LoadingSpinner";
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -168,7 +169,7 @@ export function SettingsSyncLog({
</div>
{loading && entries.length === 0 ? (
<div className="settings-sync-log__empty">{t("syncLog.loading", "Loading...")}</div>
<div className="settings-sync-log__empty"><LoadingSpinner label={t("syncLog.loading", "Loading...")} /></div>
) : filteredEntries.length === 0 ? (
<div className="settings-sync-log__empty">{t("syncLog.noHistory", "No sync history available")}</div>
) : (

View File

@@ -3,6 +3,7 @@ import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { fetchDiscoveredSkills } from "../api";
import type { DiscoveredSkill } from "../api";
import { LoadingSpinner } from "./LoadingSpinner";
export interface SkillMultiselectProps {
/** Currently selected skill IDs */
@@ -116,7 +117,7 @@ export function SkillMultiselect({
<div className="skill-multiselect-add">
{isLoading ? (
<span className="skill-multiselect-loading" data-testid="skills-loading">
{t("skills.loading", "Loading skills…")}
<LoadingSpinner label={t("skills.loading", "Loading skills…")} />
</span>
) : availableSkills.length === 0 ? (
<span className="skill-multiselect-empty" data-testid="skills-empty">

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api";
import { useConfirm } from "../hooks/useConfirm";
import { LoadingSpinner } from "./LoadingSpinner";
import "./StashRecoveryView.css";
type RecordItem = {
@@ -127,7 +128,7 @@ export function StashRecoveryView() {
&times;
</button>
</div>
{diffState.loading && <p>{t("stashRecovery.loadingDiff", "Loading diff…")}</p>}
{diffState.loading && <p><LoadingSpinner label={t("stashRecovery.loadingDiff", "Loading diff…")} /></p>}
{diffState.error && <div className="form-error">{diffState.error}</div>}
{!diffState.loading && !diffState.error && (
<>

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { FileCode, ChevronDown, ChevronRight, ChevronLeft, AlertCircle, GitCommit, WrapText, Maximize2 } from "lucide-react";
import type { MergeDetails, ColumnId } from "@fusion/core";
import { LoadingSpinner } from "./LoadingSpinner";
import { getErrorMessage } from "@fusion/core";
import {
fetchTaskDiff,
@@ -290,8 +291,7 @@ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetai
return (
<div className="detail-section">
<div className="task-changes-state task-changes-state--loading">
<div className="loading-spinner" />
<span>{t("taskChanges.loading", "Loading changes...")}</span>
<LoadingSpinner label={t("taskChanges.loading", "Loading changes...")} />
</div>
</div>
);

View File

@@ -48,6 +48,7 @@ import { TaskTokenStatsPanel } from "./TaskTokenStatsPanel";
import { BranchGroupCard } from "./BranchGroupCard";
import { PluginSlot } from "./PluginSlot";
import { ProviderIcon } from "./ProviderIcon";
import { LoadingSpinner } from "./LoadingSpinner";
import { subscribeSse } from "../sse-bus";
import type { SessionTerminalMode, SessionTerminalPosture } from "./SessionTerminal";
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
@@ -3492,7 +3493,7 @@ export function TaskDetailContent({
) : activeTab === "terminal" ? (
<div className="detail-section detail-section--terminal">
{cliSession && cliTabVisibility.kind !== "hidden" ? (
<Suspense fallback={<div className="detail-loading">{t("taskDetail.terminal.loading", "Loading terminal…")}</div>}>
<Suspense fallback={<div className="detail-loading"><LoadingSpinner label={t("taskDetail.terminal.loading", "Loading terminal…")} /></div>}>
<LazySessionTerminal
sessionId={cliSession.id}
projectId={projectId}
@@ -3672,7 +3673,7 @@ export function TaskDetailContent({
)}
{showAgentPicker && (
<div className="agent-picker-dropdown">
{agentsLoading && <div className="agent-picker-loading">{t("taskDetail.agent.loadingAgents", "Loading agents...")}</div>}
{agentsLoading && <div className="agent-picker-loading"><LoadingSpinner label={t("taskDetail.agent.loadingAgents", "Loading agents...")} /></div>}
{!agentsLoading && agents.map((a) => (
<button
key={a.id}
@@ -3782,7 +3783,7 @@ export function TaskDetailContent({
</div>
</div>
) : detailLoading ? (
<div className="spec-loading">{t("taskDetail.spec.loading", "Loading specification…")}</div>
<div className="spec-loading"><LoadingSpinner label={t("taskDetail.spec.loading", "Loading specification…")} /></div>
) : workingTask.prompt ? (
<div className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownLinkifyComponents}>

View File

@@ -13,6 +13,7 @@ import {
putTaskDocument,
deleteTaskDocument,
} from "../api";
import { LoadingSpinner } from "./LoadingSpinner";
// Document key validation: alphanumeric, hyphens, underscores, 1-64 chars
const DOCUMENT_KEY_REGEX = /^[a-zA-Z0-9_-]{1,64}$/;
@@ -213,7 +214,7 @@ export function TaskDocumentsTab({
return (
<div className="detail-section">
<h4>{t("taskDocuments.heading", "Documents")}</h4>
<div className="detail-log-empty">{t("taskDocuments.loading", "Loading documents…")}</div>
<div className="detail-log-empty"><LoadingSpinner label={t("taskDocuments.loading", "Loading documents…")} /></div>
</div>
);
}
@@ -325,7 +326,7 @@ export function TaskDocumentsTab({
<div className="task-document-revisions">
<h5>{t("taskDocuments.revisionHistory", "Revision History")}</h5>
{loadingRevisions ? (
<div className="detail-log-empty">{t("taskDocuments.loadingRevisions", "Loading…")}</div>
<div className="detail-log-empty"><LoadingSpinner label={t("taskDocuments.loadingRevisions", "Loading…")} /></div>
) : revisions.length <= 1 ? (
<div className="detail-log-empty">{t("taskDocuments.noPreviousRevisions", "No previous revisions.")}</div>
) : (

View File

@@ -6,6 +6,7 @@ import { fetchModels, fetchSettings, fetchWorkflows, refineText, getRefineErrorM
import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { NodeHealthDot } from "./NodeHealthDot";
import { LoadingSpinner } from "./LoadingSpinner";
import { Sparkles, ChevronUp, ChevronDown, Maximize2, Minimize2 } from "lucide-react";
import { REPO_OVERRIDE_RE, resolveEffectiveGithubRepoDefault } from "./githubTracking";
@@ -1108,7 +1109,7 @@ export function TaskForm({
</div>
)}
{modelsLoading ? (
<div className="model-selector-loading">{t("taskForm.loadingModels", "Loading models…")}</div>
<div className="model-selector-loading"><LoadingSpinner label={t("taskForm.loadingModels", "Loading models…")} /></div>
) : availableModels.length === 0 ? (
<small>{t("taskForm.noModelsAvailable", "No models available. Configure authentication in Settings.")}</small>
) : (
@@ -1292,7 +1293,7 @@ export function TaskForm({
<label htmlFor="task-workflow-select">{t("taskForm.workflowLabel", "Workflow")}</label>
{workflowsLoading ? (
<div className="workflow-select-loading" data-testid="task-workflow-loading">
{t("taskForm.workflowsLoading", "Loading workflows…")}
<LoadingSpinner label={t("taskForm.workflowsLoading", "Loading workflows…")} />
</div>
) : workflows.length === 0 ? (
// Built-ins are always present, so an empty list means the fetch

View File

@@ -11,6 +11,7 @@ import { fetchTaskReview, refreshTaskReview, reviseTaskReviewItems, updateTask }
import type { SelectedReviewItem } from "../api";
import type { ToastType } from "../hooks/useToast";
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
import { LoadingSpinner } from "./LoadingSpinner";
interface Props {
task: Task | TaskDetail;
@@ -373,7 +374,7 @@ export function TaskReviewTab({
source: formatRefreshSource(review?.refreshSource, t),
})}</span>
</div>
{loading ? <div className="task-review-tab__meta">{t("taskReview.loadingData", "Loading review data…")}</div> : null}
{loading ? <div className="task-review-tab__meta"><LoadingSpinner label={t("taskReview.loadingData", "Loading review data…")} /></div> : null}
{!loading && error ? <div className="task-review-tab__error">{error}</div> : null}
{!loading && !error && !isPrMode && displayItems.length === 0 ? <div className="task-review-tab__empty">{emptyMessage ?? t("taskReview.noFeedbackDirect", "No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.")}</div> : null}
{!loading && !error && displayItems.length > 0 ? (

View File

@@ -19,6 +19,7 @@ import { createTask, fetchAgents } from "../api";
import type { Agent } from "../api";
import { useTodoLists } from "../hooks/useTodoLists";
import { useConfirm } from "../hooks/useConfirm";
import { LoadingSpinner } from "./LoadingSpinner";
import "./TodoView.css";
interface TodoViewProps {
@@ -684,7 +685,7 @@ export function TodoView({
}}
>
{agentsLoading ? (
<div className="todo-agent-picker-loading">{t("todo.loadingAgents", "Loading agents...")}</div>
<div className="todo-agent-picker-loading"><LoadingSpinner label={t("todo.loadingAgents", "Loading agents...")} /></div>
) : agents.length > 0 ? (
agents
.map((agent) => (

View File

@@ -58,6 +58,7 @@ import {
} from "./settings";
import type { ToastType } from "../hooks/useToast";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { LoadingSpinner } from "./LoadingSpinner";
import "./WorkflowSettingsPanel.css";
interface WorkflowSettingsPanelProps {
@@ -922,7 +923,7 @@ function ValuesTab({
{settings.length === 0 ? (
<p className="wf-settings-empty">
{loading
? t("workflowSettings.loading", "Loading…")
? <LoadingSpinner label={t("workflowSettings.loading", "Loading…")} />
: t("workflowSettings.noDeclarations", "This workflow declares no settings, so there are no values to edit.")}
</p>
) : (

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import { LoadingSpinner } from "../LoadingSpinner";
describe("LoadingSpinner", () => {
it("renders a lucide svg carrying the animate-spin utility (so it actually spins)", () => {
const { container } = render(<LoadingSpinner label="Loading…" />);
const svg = container.querySelector("svg");
expect(svg).not.toBeNull();
// The animation only runs if the svg has the global spin utility class.
expect(svg).toHaveClass("animate-spin");
expect(svg).toHaveAttribute("fill", "none"); // lucide stroke-only loader
});
it("shows the provided label text alongside the spinner", () => {
render(<LoadingSpinner label="Loading specification…" />);
expect(screen.getByText("Loading specification…")).toBeInTheDocument();
});
it("exposes a live status region for assistive tech", () => {
render(<LoadingSpinner label="Loading…" />);
const status = screen.getByRole("status");
expect(status).toHaveAttribute("aria-live", "polite");
expect(status).toHaveClass("loading-spinner");
});
it("renders an icon-only spinner when no label is given", () => {
const { container } = render(<LoadingSpinner />);
expect(container.querySelector("svg")).toHaveClass("animate-spin");
expect(container.querySelector(".loading-spinner__label")).toBeNull();
});
it("merges a caller className onto the wrapper for layout slotting", () => {
render(<LoadingSpinner label="Loading…" className="spec-loading" />);
const status = screen.getByRole("status");
expect(status).toHaveClass("loading-spinner");
expect(status).toHaveClass("spec-loading");
});
});

View File

@@ -4,6 +4,7 @@ import { AlertCircle, Gauge } from "lucide-react";
import type { ActivityAnalytics, ColorTheme, LiveSnapshot, SignalsAnalytics, ThemeMode, TokenAnalytics, ToolAnalytics } from "@fusion/core";
import { api } from "../../api/legacy";
import { DateRangePicker, defaultPresets, rangeFromPreset, type DateRange } from "./DateRangePicker";
import { LoadingSpinner } from "../LoadingSpinner";
import { TokensArea } from "./areas/TokensArea";
import { ToolsArea } from "./areas/ToolsArea";
import { ActivityArea } from "./areas/ActivityArea";
@@ -274,7 +275,7 @@ function OverviewTab({
{controlsSection}
<div className="cc-loading" data-testid="command-center-overview-loading">
<div className="cc-chart-skeleton" />
<p>{t("commandCenter.loading", "Loading command center...")}</p>
<p><LoadingSpinner label={t("commandCenter.loading", "Loading command center...")} /></p>
</div>
{throughputSection}
</div>

View File

@@ -9,6 +9,7 @@ import type { CostResult, OrgTreeNode, TeamAgentSummary, TeamAnalytics } from "@
import { fetchExecutorStats, fetchOrgTree } from "../../../api/legacy";
import { useAppSettings } from "../../../hooks/useAppSettings";
import { AgentAvatar } from "../../AgentAvatar";
import { LoadingSpinner } from "../../LoadingSpinner";
import type { DateRange } from "../DateRangePicker";
import { Bar, type BarDatum } from "../charts/Bar";
import { Sparkline } from "../charts/Sparkline";
@@ -292,7 +293,7 @@ export function TeamArea({ range, projectId }: { range: DateRange; projectId?: s
</div>
<div className="cc-team-org-scroll" aria-live="polite">
{orgTreeState.status === "loading" ? (
<p className="cc-team-muted">{t("commandCenter.controls.orgChart.loading", "Loading org chart…")}</p>
<p className="cc-team-muted"><LoadingSpinner label={t("commandCenter.controls.orgChart.loading", "Loading org chart…")} /></p>
) : orgTreeState.status === "error" ? (
<p className="cc-team-error" role="alert">{orgTreeState.error}</p>
) : orgTreeState.data.length === 0 ? (

View File

@@ -8,6 +8,7 @@ import { LlamaCppProviderCard } from "../../LlamaCppProviderCard";
import { ProviderIcon } from "../../ProviderIcon";
import { PluginSlot } from "../../PluginSlot";
import { LoginInstructions } from "../../LoginInstructions";
import { LoadingSpinner } from "../../LoadingSpinner";
import { OAuthManualCodeForm } from "../../OAuthManualCodeForm";
import { CustomProvidersSection } from "../../CustomProvidersSection";
import { copyTextToClipboard } from "../../../utils/copyToClipboard";
@@ -80,7 +81,7 @@ export function AuthenticationSection({ auth }: AuthenticationSectionProps) {
(llamaCppProvider && !llamaCppProvider.authenticated);
return (<>
<h4 className="settings-section-heading">{t("settings.auth.title", "Authentication")}</h4>
{authLoading ? (<div className="settings-empty-state">{t("settings.auth.loadingStatus", "Loading authentication status…")}</div>) : authProviders.length === 0 ? (<div className="settings-empty-state settings-muted">
{authLoading ? (<div className="settings-empty-state"><LoadingSpinner label={t("settings.auth.loadingStatus", "Loading authentication status…")} /></div>) : authProviders.length === 0 ? (<div className="settings-empty-state settings-muted">
{t("settings.auth.noProviders", "No providers available")}
</div>) : (<div className="auth-panel-body">
<PluginSlot slotId="settings-provider-card" projectId={projectId} renderPlaceholder={false} actions={{ refreshAuthProviders: () => { void loadAuthStatus(); } }}/>

View File

@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { BackupListResponse } from "../../../api";
import type { SectionBaseProps } from "./context";
import { LoadingSpinner } from "../../LoadingSpinner";
export interface BackupsSectionProps extends SectionBaseProps {
scopeBanner: ReactNode;
backupInfo: BackupListResponse | null;
@@ -75,7 +76,7 @@ export function BackupsSection({ scopeBanner, form, setForm, backupInfo, backupL
<option value="agents">{t("settings.backups.agentsOnlyFusionAgentMemory", "Agents only (.fusion/agent-memory)")}</option>
</select>
</div>
{backupLoading ? (<div className="settings-empty-state">{t("settings.backups.loadingBackupInfo", "Loading backup info\u2026")}</div>) : backupInfo ? (<div className="form-group">
{backupLoading ? (<div className="settings-empty-state"><LoadingSpinner label={t("settings.backups.loadingBackupInfo", "Loading backup info\u2026")} /></div>) : backupInfo ? (<div className="form-group">
<label>{t("settings.backups.currentBackups", "Current Backups")}</label>
<div className="backup-stats">
<div className="backup-stat">

View File

@@ -5,6 +5,7 @@ import type { Settings, ThinkingLevel } from "@fusion/core";
import type { ModelInfo } from "../../../api";
import { CustomModelDropdown } from "../../CustomModelDropdown";
import type { SectionBaseProps, ModelLane } from "./context";
import { LoadingSpinner } from "../../LoadingSpinner";
function toCommaSeparatedInput(values?: string[]): string {
return values?.join(", ") ?? "";
}
@@ -32,7 +33,7 @@ export function GlobalModelsSection({ scopeBanner, form, setForm, availableModel
{/* --- Default Model --- */}
<h4 className="settings-section-heading">{t("settings.globalModels.defaultModel", "Default Model")}</h4>
{modelsLoading ? (<div className="settings-empty-state">{t("settings.models.loadingModels", "Loading available models…")}</div>) : availableModels.length === 0 ? (<div className="settings-empty-state settings-muted">
{modelsLoading ? (<div className="settings-empty-state"><LoadingSpinner label={t("settings.models.loadingModels", "Loading available models…")} /></div>) : availableModels.length === 0 ? (<div className="settings-empty-state settings-muted">
{t("settings.models.noModels", "No models available. Configure authentication first.")}
</div>) : (<>
<div className="form-group">

View File

@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import type { MemoryBackendCapabilities, MemoryBackendStatus, MemoryFileInfo, MemoryRetrievalTestResult, } from "../../../api";
import { FileEditor } from "../../FileEditor";
import type { SectionBaseProps } from "./context";
import { LoadingSpinner } from "../../LoadingSpinner";
const MEMORY_FILE_OPTION_LABEL_MAX_CHARS = 72;
function truncateMiddle(value: string, maxChars: number): string {
if (value.length <= maxChars) {
@@ -164,7 +165,7 @@ export function MemorySection({ scopeBanner, form, setForm, memory }: MemorySect
{!isMemoryEnabled && (<div className="settings-empty-state memory-status-message">{t("settings.memory.memoryIsCurrentlyDisabledYouCanViewThe", " Memory is currently disabled. You can view the file, but editing is read-only until memory is re-enabled. ")}</div>)}
{isMemoryEnabled && backendStatusResolved && !isBackendWritable && (<div className="settings-empty-state memory-status-message">{t("settings.memory.memoryIsConfiguredWithAReadOnlyBackend", " Memory is configured with a read-only backend. You can view the file, but saving is disabled. ")}</div>)}
{memoryLoading ? (<div className="settings-empty-state">{t("settings.memory.loadingMemory", "Loading memory\u2026")}</div>) : (<div className="memory-editor-section">
{memoryLoading ? (<div className="settings-empty-state"><LoadingSpinner label={t("settings.memory.loadingMemory", "Loading memory\u2026")} /></div>) : (<div className="memory-editor-section">
<div className="form-group">
<label htmlFor="memoryFilePath">{t("settings.memory.memoryFile", "Memory File")}</label>
<select id="memoryFilePath" value={selectedMemoryPath} onChange={(e) => {

View File

@@ -7,6 +7,7 @@ import { CustomModelDropdown } from "../../CustomModelDropdown";
import { applyPresetToSelection } from "../../../utils/modelPresets";
import type { ToastType } from "../../../hooks/useToast";
import type { ModelLane, SectionBaseProps, SectionSaveHandler, SettingsFormState } from "./context";
import { LoadingSpinner } from "../../LoadingSpinner";
type LaneStatus = "inherited" | "overridden";
type WorkflowModelPair = {
id: "planning" | "execution" | "validator" | "planning-fallback" | "validator-fallback" | "title-summarizer-fallback";
@@ -270,7 +271,7 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
{/* --- Project Model Lanes --- */}
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.projectModels.modelLanes", "Model Lanes")}</h4>
<p className="settings-description">{t("settings.projectModels.overrideGlobalModelSettingsAtTheProjectLevel", " Override global model settings at the project level. Each lane controls a specific AI usage context. Unset lanes inherit from the corresponding global lane. The Project Default Model is the fallback for this project when a more specific lane is unset. ")}</p>
{modelsLoading ? (<div className="settings-empty-state">{t("settings.projectModels.loadingAvailableModels", "Loading available models\u2026")}</div>) : availableModels.length === 0 ? (<div className="settings-empty-state settings-muted">{t("settings.projectModels.noModelsAvailableConfigureAuthenticationFirst", " No models available. Configure authentication first. ")}</div>) : (<>
{modelsLoading ? (<div className="settings-empty-state"><LoadingSpinner label={t("settings.projectModels.loadingAvailableModels", "Loading available models\u2026")} /></div>) : availableModels.length === 0 ? (<div className="settings-empty-state settings-muted">{t("settings.projectModels.noModelsAvailableConfigureAuthenticationFirst", " No models available. Configure authentication first. ")}</div>) : (<>
{projectModelLanes.map((lane) => {
const status = getLaneStatus(lane);
const value = getLaneValue(lane);
@@ -300,7 +301,7 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.projectModels.defaultWorkflowModelLanes", "Default workflow model lanes")}</h4>
<p className="settings-description">
{t("settings.movedStub.modelLanes", "Per-phase model lanes (execution, planning, reviewer, and their fallbacks) now live on the workflow.")}{t("settings.projectModels.theseProjectOverridesApplyToTheActiveDefault", " These project overrides apply to the active default workflow. ")}</p>
{!projectId ? (<div className="settings-empty-state settings-muted">{t("settings.projectModels.openAProjectToEditWorkflowModelLanes", "Open a project to edit workflow model lanes.")}</div>) : workflowLoading ? (<div className="settings-empty-state">{t("settings.projectModels.loadingWorkflowModelLanes", "Loading workflow model lanes\u2026")}</div>) : availableModels.length === 0 ? (<div className="settings-empty-state settings-muted">{t("settings.projectModels.noModelsAvailableConfigureAuthenticationBeforeSelectingWorkflow", " No models available. Configure authentication before selecting workflow model lanes. ")}</div>) : (<>
{!projectId ? (<div className="settings-empty-state settings-muted">{t("settings.projectModels.openAProjectToEditWorkflowModelLanes", "Open a project to edit workflow model lanes.")}</div>) : workflowLoading ? (<div className="settings-empty-state"><LoadingSpinner label={t("settings.projectModels.loadingWorkflowModelLanes", "Loading workflow model lanes\u2026")} /></div>) : availableModels.length === 0 ? (<div className="settings-empty-state settings-muted">{t("settings.projectModels.noModelsAvailableConfigureAuthenticationBeforeSelectingWorkflow", " No models available. Configure authentication before selecting workflow model lanes. ")}</div>) : (<>
{workflowModelPairs.map((pair) => {
const value = modelPairValue(effectiveWorkflowValues, pair);
const customized = Object.prototype.hasOwnProperty.call(workflowPending, pair.providerId)