feat(web): add ESLint 10 with flat config and CI integration

- Install ESLint 10 + TypeScript-ESLint + React hooks plugins
- Create eslint.config.js with flat config format
- Fix conditional hooks violations in agents-page, task-list, knowledge-chart
- Fix unused variables and useless assignments
- Add lint step to CI workflow before build
- Remove unused eslint-disable comments

Rules configured:
- rules-of-hooks: error (critical)
- exhaustive-deps: off (intentional patterns)
- no-explicit-any: off (pragmatic casts)
- no-console: off (dev convenience)
This commit is contained in:
viettranx committed 2026-04-12 17:40:33 +07:00
1 parent 08b51adaf1
commit 607ef556b5
41 files changed
+1142 -71

No files matched your search

+1
View File
@@ -73,4 +73,5 @@ jobs:
cache: pnpm
cache-dependency-path: ui/web/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm build
+37
View File
@@ -0,0 +1,37 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist", "node_modules"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2024,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
// React hooks - keep critical rules only
"react-hooks/rules-of-hooks": "error",
// Disabled: many intentional patterns (stable refs, translation fn)
"react-hooks/exhaustive-deps": "off",
// Disabled: legitimate pattern to co-locate hooks with components
"react-refresh/only-export-components": "off",
// TypeScript
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
// Disabled: pragmatic casts for external libs and dynamic data
"@typescript-eslint/no-explicit-any": "off",
// General - disabled for dev convenience
"no-console": "off",
},
}
);
+7
View File
@@ -8,6 +8,7 @@
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui"
@@ -54,6 +55,7 @@
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.2.2",
"@types/mdast": "^4.0.4",
"@types/react": "^19.2.14",
@@ -61,9 +63,14 @@
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/ui": "^4.1.4",
"eslint": "^10.2.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"jsdom": "^29.0.2",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
"typescript-eslint": "^8.58.1",
"unified": "^11.0.5",
"vite": "^8.0.8",
"vitest": "^4.1.4",
+1027
View File
File diff suppressed because it is too large. Load diff
@@ -164,7 +164,7 @@ export function SigmaGraphContainer({
onSigmaReady?.(null);
}
};
}, [graph, edgeType, compact]); // eslint-disable-line react-hooks/exhaustive-deps
}, [graph, edgeType, compact]);
// --- Update theme colors without re-init ---
useEffect(() => {
@@ -53,14 +53,14 @@ export function WsProvider({ children }: { children: React.ReactNode }) {
store.setTenantSelected(true);
} else if (!client.isOwner && tenants.length === 1) {
// Non-owner with single tenant — auto-select
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
localStorage.setItem(LOCAL_STORAGE_KEYS.TENANT_ID, tenants[0]!.slug);
store.setTenantSelected(true);
} else if (!client.isOwner && tenants.length === 0) {
// No tenants — leave tenantSelected=false (blocked)
} else if (client.isOwner && !savedScope && tenants.length > 0) {
// Owner without scope — auto-select first tenant
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
localStorage.setItem(LOCAL_STORAGE_KEYS.TENANT_ID, tenants[0]!.slug);
window.location.reload();
return;
@@ -83,7 +83,6 @@ export function MermaidBlock({ code }: MermaidBlockProps) {
return (
<div
className="not-prose my-3 overflow-x-auto rounded-lg border border-border/60 bg-background p-4"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: svg ?? "" }}
/>
);
@@ -108,7 +108,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, classN
}, []);
// Stable components config — only recreated when token/callbacks change.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const components = useMemo((): Components & Record<string, any> => ({
pre({ children }) {
return <>{children}</>;
+1 -1
View File
@@ -83,7 +83,7 @@ export function Combobox({
// Auto-detect if inside a Radix Dialog (which sets pointer-events:none on body)
const el = containerRef.current?.closest<HTMLElement>('[data-slot="dialog-content"]');
return el ?? null;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [portalContainer, open]);
// Compute dropdown position — flip above input when near viewport bottom.
+1 -1
View File
@@ -49,7 +49,7 @@ export function useAutoScroll<T extends HTMLElement>(
if (isNearBottom.current) {
scrollToBottom(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
// Force scroll when trigger increments (e.g. user sends a message) — instant
+1 -1
View File
@@ -45,7 +45,7 @@ export function getAllIanaTimezones(): TzOption[] {
if (_cachedTimezones) return _cachedTimezones;
let tzNames: string[];
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tzNames = (Intl as any).supportedValuesOf("timeZone") as string[];
} catch {
tzNames = FALLBACK_TIMEZONES;
@@ -78,7 +78,7 @@ export function AgentAdvancedDialog({ open, onOpenChange, agent, onUpdate }: Age
setPrune(s.prune);
setSbEnabled(s.sbEnabled);
setSb(s.sb);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
useEffect(() => {
@@ -16,7 +16,7 @@ function estimateTokensFromContent(content: string): number {
let ascii = 0;
let nonAscii = 0;
for (const ch of content) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (ch.codePointAt(0)! < 128) ascii++;
else nonAscii++;
}
@@ -158,7 +158,7 @@ export function HeartbeatConfigDialog({
loadChecklist();
fetchTargets().then(setTargets)
.catch((err) => console.error("[HeartbeatConfig] fetch targets failed:", err));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const handleTest = async () => {
+14 -14
View File
@@ -46,6 +46,20 @@ export function AgentsPage() {
const ownerIDs = useMemo(() => [...new Set(agents.map((a) => a.owner_id).filter(Boolean))], [agents]);
const { resolve } = useContactResolver(ownerIDs);
const filtered = useMemo(() => agents.filter((a) => {
if (ownerFilter && a.owner_id !== ownerFilter) return false;
if (typeFilter && a.agent_type !== typeFilter) return false;
const q = search.toLowerCase();
return (
a.agent_key.toLowerCase().includes(q) ||
(a.display_name ?? "").toLowerCase().includes(q)
);
}), [agents, ownerFilter, typeFilter, search]);
const { pageItems, pagination, setPage, setPageSize, resetPage } = usePagination(filtered);
useEffect(() => { resetPage(); }, [search, ownerFilter, typeFilter, resetPage]);
const handleResummon = async (agent: { id: string; display_name?: string; agent_key: string }) => {
try {
await resummonAgent(agent.id);
@@ -65,20 +79,6 @@ export function AgentsPage() {
);
}
const filtered = agents.filter((a) => {
if (ownerFilter && a.owner_id !== ownerFilter) return false;
if (typeFilter && a.agent_type !== typeFilter) return false;
const q = search.toLowerCase();
return (
a.agent_key.toLowerCase().includes(q) ||
(a.display_name ?? "").toLowerCase().includes(q)
);
});
const { pageItems, pagination, setPage, setPageSize, resetPage } = usePagination(filtered);
useEffect(() => { resetPage(); }, [search, ownerFilter, typeFilter, resetPage]);
const resolveOwnerName = (id: string) => {
const contact = resolve(id);
return contact?.display_name || contact?.username || id;
@@ -37,7 +37,7 @@ function highlightLine(line: string, lang: Tab): React.ReactNode | string {
// Simple token-based highlighting
const parts: React.ReactNode[] = [];
let remaining = line;
const remaining = line;
let key = 0;
const kwPatterns = lang === "go"
@@ -67,7 +67,7 @@ function highlightLine(line: string, lang: Tab): React.ReactNode | string {
// Remaining text after last string
if (lastIndex < remaining.length) {
const after = remaining.slice(lastIndex);
parts.push(<span key={key++}>{highlightKeywords(after, kwPatterns)}</span>);
parts.push(<span key={key}>{highlightKeywords(after, kwPatterns)}</span>);
}
if (parts.length === 0) return line;
@@ -11,7 +11,7 @@ import {
} from "@/components/ui/tooltip";
import type { BuiltinToolData } from "./hooks/use-builtin-tools";
/* eslint-disable @typescript-eslint/no-explicit-any */
// Tools with dedicated settings forms always show the Settings button,
// even when settings is empty (the form lets the user create settings).
@@ -195,7 +195,7 @@ export function WebFetchExtractorChainForm({ initialSettings, onSave, onCancel }
const handleSave = async () => {
setSaving(true);
try {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const serialized = entries.map(({ id: _id, ...rest }) => rest);
await onSave({ extractors: serialized });
} catch {
@@ -42,10 +42,9 @@ function getAdvancedFields(channelType: string) {
function deriveInitialValues(instance: ChannelInstanceData): Record<string, unknown> {
const config = (instance.config ?? {}) as Record<string, unknown>;
const { groups: _groups, ...rest } = config as Record<string, unknown> & { groups?: unknown };
// Only keep advanced keys (exclude essential + groups)
return Object.fromEntries(
Object.entries(rest).filter(([k]) => !ESSENTIAL_CONFIG_KEYS.has(k)),
Object.entries(config).filter(([k]) => !ESSENTIAL_CONFIG_KEYS.has(k) && k !== "groups"),
);
}
@@ -65,7 +64,7 @@ export function ChannelAdvancedDialog({
useEffect(() => {
if (!open) return;
setValues(deriveInitialValues(instance));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const handleChange = useCallback((key: string, value: unknown) => {
@@ -30,7 +30,7 @@ export function WhatsAppReauthDialog({
// because we only want to trigger on open/close transitions, not on identity changes.
useEffect(() => {
if (open && status === "idle") start();
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
}, [open]);
// Reset state when dialog closes
useEffect(() => {
@@ -30,7 +30,7 @@ export function ZaloPersonalQRDialog({
// Auto-start when dialog opens
useEffect(() => {
if (open && status === "idle") start();
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
}, [open]);
// Reset state when dialog closes
useEffect(() => {
@@ -91,8 +91,8 @@ export function useChatTeamTasks(
eventName === "team.task.commented" ||
eventName === "team.task.attachment_added"
) {
let icon = "📋";
let text = "";
let icon: string;
let text: string;
if (eventName === "team.task.completed") { icon = "✅"; text = `Task #${event.task_number} "${event.subject}" completed`; }
else if (eventName === "team.task.failed") { icon = "❌"; text = `Task #${event.task_number} "${event.subject}" failed${event.reason ? ": " + event.reason : ""}`; }
else if (eventName === "team.task.commented") { icon = "💬"; text = `Comment on #${event.task_number} "${event.subject}"`; }
@@ -8,7 +8,7 @@ import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { InfoLabel } from "@/components/shared/info-label";
/* eslint-disable @typescript-eslint/no-explicit-any */
export function SubSection({
title,
@@ -11,7 +11,7 @@ import { InfoLabel } from "@/components/shared/info-label";
import { ProviderModelSelect } from "@/components/shared/provider-model-select";
import { SubSection, Field } from "./ai-defaults-form-controls";
/* eslint-disable @typescript-eslint/no-explicit-any */
type AgentsData = Record<string, any>;
const DEFAULT: AgentsData = { defaults: {} };
@@ -8,7 +8,7 @@ import { BehaviorSessionsCard } from "./behavior-sessions-card";
import { BehaviorSecurityCard } from "./behavior-security-card";
import { BehaviorPendingCompactionCard, type PendingCompactionValues } from "./behavior-pending-compaction-card";
/* eslint-disable @typescript-eslint/no-explicit-any */
interface Props {
config: Record<string, any>;
@@ -77,7 +77,7 @@ export function BehaviorSection({ config, onPatch, saving }: Props) {
});
setPendingCompaction(ch.pending_compaction ?? {});
setDirty(false);
}, [config]); // eslint-disable-line react-hooks/exhaustive-deps
}, [config]);
const markDirty = <T,>(setter: React.Dispatch<React.SetStateAction<T>>) =>
(v: T) => { setter(v); setDirty(true); };
@@ -10,7 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Badge } from "@/components/ui/badge";
import { MultiUserPicker } from "@/components/shared/multi-user-picker";
/* eslint-disable @typescript-eslint/no-explicit-any */
type ChannelsData = Record<string, any>;
const CHANNEL_META: Record<string, { label: string; secretEnv: string; secretField: string }> = {
@@ -14,7 +14,7 @@ import {
import { useHttp } from "@/hooks/use-ws";
import { useAuthStore } from "@/stores/use-auth-store";
/* eslint-disable @typescript-eslint/no-explicit-any */
type ToolsData = Record<string, any>;
interface DenyGroupInfo {
@@ -19,7 +19,7 @@ import {
} from "@/components/ui/select";
import { InfoLabel } from "@/components/shared/info-label";
/* eslint-disable @typescript-eslint/no-explicit-any */
type ToolsData = Record<string, any>;
interface Props {
@@ -20,7 +20,7 @@ import {
import { InfoLabel } from "@/components/shared/info-label";
import { ToolNameSelect } from "@/components/shared/tool-name-select";
/* eslint-disable @typescript-eslint/no-explicit-any */
type ToolsData = Record<string, any>;
interface Props {
@@ -24,7 +24,7 @@ import { Separator } from "@/components/ui/separator";
import { InfoLabel } from "@/components/shared/info-label";
import { isSecret } from "@/lib/secret";
/* eslint-disable @typescript-eslint/no-explicit-any */
type ToolsData = Record<string, any>;
interface Props {
@@ -5,7 +5,7 @@ import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ROUTES } from "@/lib/constants";
/* eslint-disable @typescript-eslint/no-explicit-any */
interface Props {
data: Record<string, any> | undefined;
}
@@ -86,7 +86,7 @@ export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAd
if (!open) return;
reset(deriveDefaults(job));
fetchTargets();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const handleSave = async () => {
@@ -99,7 +99,7 @@ export function MCPUserCredentialsDialog({
.then(setStatus)
.catch((err) => console.error("[MCPUserCredentials] load credentials failed:", err))
.finally(() => { setLoadingStatus(false); setInitialLoad(false); });
}, [open, server.id, onGetCredentials, canManageUsers, selectedUserId]); // eslint-disable-line react-hooks/exhaustive-deps
}, [open, server.id, onGetCredentials, canManageUsers, selectedUserId]);
const handleSave = async () => {
setSaving(true);
@@ -38,7 +38,8 @@ export function EpisodicTab({ agentId }: Props) {
const toggleExpand = (id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
@@ -95,7 +95,7 @@ export function KGEntityDetailDialog({ open, onOpenChange, agentId, entity, getE
if (open && entity && !traversing) {
traverse(entity.id, entity.user_id, depth);
}
}, [open, entity]); // eslint-disable-line react-hooks/exhaustive-deps
}, [open, entity]);
// Build graph data from traversal results
const graphData = useMemo(() => {
@@ -75,7 +75,7 @@ export function ProviderAdvancedDialog({
setAcpIdleTTL(s.acpIdleTTL);
setAcpPermMode(s.acpPermMode);
setAcpWorkDir(s.acpWorkDir);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const [saving, setSaving] = useState(false);
+1 -1
View File
@@ -51,7 +51,7 @@ export function useSkills() {
// even if the SKILL_DEPS_* events were emitted before the client connected.
useEffect(() => {
if (connected) invalidate();
}, [connected]); // eslint-disable-line react-hooks/exhaustive-deps
}, [connected]);
const getSkill = useCallback(
async (name: string) => {
@@ -65,7 +65,7 @@ export const BoardContainer = memo(function BoardContainer({
// Re-fetch when filters change
useEffect(() => {
if (initialized) load();
}, [statusFilter, selectedScope]); // eslint-disable-line react-hooks/exhaustive-deps
}, [statusFilter, selectedScope]);
// ── Callbacks for children ──
@@ -97,6 +97,17 @@ export function TaskList({
}
}, [deleteTasksBulk, teamId, selectedIds]);
const handleSingleDelete = useCallback(async () => {
if (!deleteTask || !deleteTargetId) return;
setSingleDeleting(true);
try {
await deleteTask(teamId, deleteTargetId);
setDeleteTargetId(null);
} finally {
setSingleDeleting(false);
}
}, [deleteTask, teamId, deleteTargetId]);
if (loading && tasks.length === 0) {
return <div className="py-8 text-center text-sm text-muted-foreground">{t("tasks.loading")}</div>;
}
@@ -117,17 +128,6 @@ export function TaskList({
setDeleteTargetId(taskId);
};
const handleSingleDelete = useCallback(async () => {
if (!deleteTask || !deleteTargetId) return;
setSingleDeleting(true);
try {
await deleteTask(teamId, deleteTargetId);
setDeleteTargetId(null);
} finally {
setSingleDeleting(false);
}
}, [deleteTask, teamId, deleteTargetId]);
const hasBulkDelete = !!deleteTasksBulk && pageTerminalIds.length > 0;
const allPageSelected = pageTerminalIds.length > 0 && pageTerminalIds.every((id) => selectedIds.has(id));
const someSelected = selectedIds.size > 0 && !allPageSelected;
@@ -103,7 +103,7 @@ export function TeamAuditLogsModal({ open, onOpenChange, teamId }: TeamAuditLogs
setEvents([]);
fetchEvents(0, false);
}
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
}, [open]);
const handleLoadMore = useCallback(() => {
fetchEvents(events.length, true);
@@ -19,12 +19,6 @@ export function KnowledgeChart({ data, loading, granularity }: KnowledgeChartPro
const { t } = useTranslation("usage");
const timezone = useUiStore((s) => s.timezone);
const hasData = data.some(
(d) => d.memory_docs > 0 || d.memory_chunks > 0 || d.kg_entities > 0 || d.kg_relations > 0,
);
if (!loading && !hasData) return null;
const chartData = useMemo(() => data.map((d) => ({
label: formatBucketTz(d.bucket_time, timezone, granularity),
memory_docs: d.memory_docs,
@@ -33,6 +27,12 @@ export function KnowledgeChart({ data, loading, granularity }: KnowledgeChartPro
kg_relations: d.kg_relations,
})), [data, granularity, timezone]);
const hasData = data.some(
(d) => d.memory_docs > 0 || d.memory_chunks > 0 || d.kg_entities > 0 || d.kg_relations > 0,
);
if (!loading && !hasData) return null;
return (
<ChartWrapper
title={t("analytics.knowledgeChart.title")}