Merge pull request #1705 from Runfusion/gsxdsm/fix-spinners
fix: make loading spinners actually spin across the dashboard
This commit is contained in:
5
.changeset/fix-loading-spinners.md
Normal file
5
.changeset/fix-loading-spinners.md
Normal 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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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.")}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
11
packages/dashboard/app/components/LoadingSpinner.css
Normal file
11
packages/dashboard/app/components/LoadingSpinner.css
Normal 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;
|
||||
}
|
||||
47
packages/dashboard/app/components/LoadingSpinner.tsx
Normal file
47
packages/dashboard/app/components/LoadingSpinner.tsx
Normal 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>
|
||||
);
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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.")}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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() {
|
||||
×
|
||||
</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 && (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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(); } }}/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user