diff --git a/.github/workflows/frontend-checks.yaml b/.github/workflows/frontend-checks.yaml new file mode 100644 index 00000000..983b7787 --- /dev/null +++ b/.github/workflows/frontend-checks.yaml @@ -0,0 +1,66 @@ +name: Frontend Checks + +on: + push: + branches: + - main + paths: + - "frontend/**" + pull_request: + branches: + - main + paths: + - "frontend/**" + +permissions: + contents: read + +jobs: + frontend-lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "18" + cache: "yarn" + cache-dependency-path: "frontend/yarn.lock" + + - name: Install dependencies + working-directory: frontend + run: yarn install --frozen-lockfile + + - name: Run ESLint (must have 0 errors) + working-directory: frontend + run: | + echo "๐Ÿงน Running ESLint - checking for 0 errors..." + + # Run lint and capture output + LINT_OUTPUT=$(yarn lint 2>&1) + LINT_EXIT_CODE=$? + + echo "$LINT_OUTPUT" + + # Parse the output to check for errors (but not warnings) + if [ $LINT_EXIT_CODE -ne 0 ]; then + echo "โŒ ESLint failed with exit code $LINT_EXIT_CODE" + exit 1 + else + echo "โœ… ESLint passed with 0 errors!" + fi + + - name: Run TypeScript check + working-directory: frontend + run: | + echo "๐Ÿ” Running TypeScript check..." + yarn typecheck + + if [ $? -eq 0 ]; then + echo "โœ… TypeScript check passed!" + else + echo "โŒ TypeScript check failed" + exit 1 + fi diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js new file mode 100644 index 00000000..2d3c563a --- /dev/null +++ b/frontend/.eslintrc.js @@ -0,0 +1,50 @@ +module.exports = { + env: { + browser: true, + es2021: true, + node: true, + }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react/recommended', + 'plugin:react-hooks/recommended', + 'plugin:jsx-a11y/recommended', + ], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 'latest', + sourceType: 'module', + }, + plugins: [ + 'react', + '@typescript-eslint', + 'react-hooks', + 'jsx-a11y', + ], + rules: { + 'react/react-in-jsx-scope': 'off', + 'react/prop-types': 'off', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'warn', + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + }, + settings: { + react: { + version: 'detect', + }, + }, + ignorePatterns: [ + 'node_modules/', + 'public/', + '.cache/', + 'gatsby-*.js', + '*.config.js', + ], +}; diff --git a/frontend/gatsby-config.ts b/frontend/gatsby-config.ts index 6e980422..e3bd63fb 100644 --- a/frontend/gatsby-config.ts +++ b/frontend/gatsby-config.ts @@ -9,6 +9,7 @@ fs.access(envFile, fs.constants.F_OK, (err) => { } }); +// eslint-disable-next-line @typescript-eslint/no-var-requires require("dotenv").config({ path: envFile, }); diff --git a/frontend/package.json b/frontend/package.json index a45fb11c..5b13d574 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,8 @@ "build": "gatsby clean && rm -rf ../src/magentic_ui/backend/web/ui && PREFIX_PATH_VALUE='' gatsby build --prefix-paths && rsync -a --delete public/ ../src/magentic_ui/backend/web/ui/", "serve": "gatsby serve", "clean": "gatsby clean", + "lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 150", + "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -68,5 +70,6 @@ "prismjs": "1.30.0", "cookie": "0.7.0", "base-x": "3.0.11" - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/frontend/src/components/common/Icon.tsx b/frontend/src/components/common/Icon.tsx index a1390c15..6269a1c7 100644 --- a/frontend/src/components/common/Icon.tsx +++ b/frontend/src/components/common/Icon.tsx @@ -8,14 +8,12 @@ interface IconProps { } const IconWrapper: React.FC = ({ - className = "", - size = 16, tooltip, children }) => { const uniqueId = useId(); const groupClass = `tooltip-group-${uniqueId.replace(/:/g, '')}`; - + return (
{children} diff --git a/frontend/src/components/common/filerenderer.tsx b/frontend/src/components/common/filerenderer.tsx index 8a9fff6a..204bd27c 100644 --- a/frontend/src/components/common/filerenderer.tsx +++ b/frontend/src/components/common/filerenderer.tsx @@ -98,7 +98,7 @@ const FileModal: React.FC = ({ file, content, }) => { - const [isFullScreen, setIsFullScreen] = useState(false); + const [isFullScreen] = useState(false); const modalRef = React.useRef(null); const [downloadUrl, setDownloadUrl] = useState(null); const [isLoading, setIsLoading] = useState(false); @@ -171,9 +171,9 @@ const FileModal: React.FC = ({ if (!isOpen || !file) return null; - const toggleFullScreen = (): void => { - setIsFullScreen(!isFullScreen); - }; + // const toggleFullScreen = (): void => { + // setIsFullScreen(!isFullScreen); + // }; // Handle click outside the modal content const handleBackdropClick = (e: React.MouseEvent) => { @@ -254,12 +254,22 @@ const FileModal: React.FC = ({
{ + if (e.key === 'Escape') { + onClose(); + } + }} + role="presentation" + aria-label="File modal" + tabIndex={-1} >
{/* Header */}
@@ -406,6 +416,10 @@ const FileCard = memo(({ file, onFileClick }) => {
onFileClick(file)} + onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onFileClick(file)} + role="button" + tabIndex={0} + aria-label={`View ${file.name}`} >
@@ -422,6 +436,10 @@ const FileCard = memo(({ file, onFileClick }) => {
onFileClick(file)} + onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onFileClick(file)} + role="button" + tabIndex={0} + aria-label={`View ${file.name}`} > diff --git a/frontend/src/components/common/markdownrender.tsx b/frontend/src/components/common/markdownrender.tsx index fd3870d8..982a4c8c 100644 --- a/frontend/src/components/common/markdownrender.tsx +++ b/frontend/src/components/common/markdownrender.tsx @@ -222,7 +222,7 @@ const MarkdownRenderer: React.FC = ({ {children} ), - code: ({ node, className, children, ...props }) => { + code: ({ className, children, ...props }) => { const match = /language-(\w+)/.exec(className || ""); const language = match ? match[1] : ""; const inline = !language; diff --git a/frontend/src/components/contentheader.tsx b/frontend/src/components/contentheader.tsx index 61014215..312ef232 100644 --- a/frontend/src/components/contentheader.tsx +++ b/frontend/src/components/contentheader.tsx @@ -77,6 +77,10 @@ const ContentHeader = ({
setIsEmailModalOpen(true)} + onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && setIsEmailModalOpen(true)} + role="button" + tabIndex={0} + aria-label="View or update your profile" > {user.avatar_url ? ( = ({ }) => { const [isLearning, setIsLearning] = useState(false); const [isLearned, setIsLearned] = useState(false); - const [error, setError] = useState(null); + const [, setError] = useState(null); const { user, darkMode } = useContext(appContext); const planAPI = new PlanAPI(); diff --git a/frontend/src/components/features/Plans/PlanCard.tsx b/frontend/src/components/features/Plans/PlanCard.tsx index 02eed528..336ae661 100644 --- a/frontend/src/components/features/Plans/PlanCard.tsx +++ b/frontend/src/components/features/Plans/PlanCard.tsx @@ -268,10 +268,11 @@ const PlanCard: React.FC = ({ {isModalOpen && (
-
-
e.stopPropagation()}> +
e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation(); + } + }} + role="button" + tabIndex={0} + > {alt} - {alt} setIsFullScreen(true)} - /> + className="block border-0 bg-transparent p-0 cursor-zoom-in" + aria-label="Click to view fullscreen" + > + {alt} + {isFullScreen && ( = ({

- Magentic-UI can't see what you do when you take control. + Magentic-UI can't see what you do when you take control.

Please describe what you did when you are ready to hand back diff --git a/frontend/src/components/views/chat/DetailViewer/browser_iframe.tsx b/frontend/src/components/views/chat/DetailViewer/browser_iframe.tsx index 2b42c787..47c7286f 100644 --- a/frontend/src/components/views/chat/DetailViewer/browser_iframe.tsx +++ b/frontend/src/components/views/chat/DetailViewer/browser_iframe.tsx @@ -117,6 +117,14 @@ const BrowserIframe: React.FC = ({

{ + if (e.key === 'Enter' || e.key === ' ') { + handleOverlayClick(); + } + }} + role="button" + tabIndex={0} + aria-label="Take control of browser" >
Take Control
diff --git a/frontend/src/components/views/chat/DetailViewer/fullscreen_overlay.tsx b/frontend/src/components/views/chat/DetailViewer/fullscreen_overlay.tsx index 8e0f016e..e4ca1665 100644 --- a/frontend/src/components/views/chat/DetailViewer/fullscreen_overlay.tsx +++ b/frontend/src/components/views/chat/DetailViewer/fullscreen_overlay.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from "react"; import FeedbackForm from "./FeedbackForm"; +import { IPlan } from "../../../types/plan"; interface FullscreenOverlayProps { isVisible: boolean; diff --git a/frontend/src/components/views/chat/approval_buttons.tsx b/frontend/src/components/views/chat/approval_buttons.tsx index 4554655b..a9e1d20c 100644 --- a/frontend/src/components/views/chat/approval_buttons.tsx +++ b/frontend/src/components/views/chat/approval_buttons.tsx @@ -22,7 +22,7 @@ const ApprovalButtons: React.FC = ({ onAcceptPlan, onRegeneratePlan, }) => { - const [planAcceptText, setPlanAcceptText] = React.useState(""); + const [planAcceptText] = React.useState(""); if (status !== "awaiting_input") { return null; diff --git a/frontend/src/components/views/chat/chat.tsx b/frontend/src/components/views/chat/chat.tsx index 1622aac7..db5046c3 100644 --- a/frontend/src/components/views/chat/chat.tsx +++ b/frontend/src/components/views/chat/chat.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import { message } from "antd"; -import { convertFilesToBase64, getServerUrl } from "../../utils"; +import { convertFilesToBase64 } from "../../utils"; import { IStatus } from "../../types/app"; import { Run, @@ -76,7 +76,6 @@ export default function ChatView({ visible = true, onRunStatusChange, }: ChatViewProps) { - const serverUrl = getServerUrl(); const [error, setError] = React.useState({ status: true, message: "All good", @@ -103,7 +102,7 @@ export default function ChatView({ const [activeSocket, setActiveSocket] = React.useState( null ); - const [teamConfig, setTeamConfig] = React.useState( + const [teamConfig] = React.useState( defaultTeamConfig ); @@ -318,8 +317,9 @@ export default function ChatView({ activeSocketRef.current = null; } console.log("Error: ", message.error); + return current; - case "message": + case "message": { if (!message.data) return current; // Create new Message object from websocket data @@ -333,24 +333,26 @@ export default function ChatView({ ...current, messages: [...current.messages, newMessage], }; + } - case "input_request": + case "input_request": { //console.log("InputRequest: " + JSON.stringify(message)) - var input_request: InputRequest; + let input_request: InputRequest; switch (message.input_type) { case "text_input": case null: default: input_request = { input_type: "text_input" }; break; - case "approval": - var input_request_message = message as InputRequestMessage; + case "approval": { + const input_request_message = message as InputRequestMessage; input_request = { input_type: "approval", prompt: input_request_message.prompt, } as InputRequest; break; + } } // reset Updated Plan @@ -368,6 +370,8 @@ export default function ChatView({ status: "awaiting_input", input_request: input_request, }; + } + case "system": // update run status return { @@ -376,7 +380,7 @@ export default function ChatView({ }; case "result": - case "completion": + case "completion": { const status: BaseRunStatus = message.status === "complete" ? "complete" @@ -406,6 +410,7 @@ export default function ChatView({ team_result: message.data && isTeamResult(message.data) ? message.data : null, }; + } default: return current; @@ -442,7 +447,7 @@ export default function ChatView({ try { // Check if the last message is a plan const lastMessage = currentRun.messages.slice(-1)[0]; - var planString = ""; + let planString = ""; if (plan) { planString = convertPlanStepsToJsonString(plan.steps); } else if ( @@ -493,7 +498,7 @@ export default function ChatView({ try { // Check if the last message is a plan const lastMessage = currentRun.messages.slice(-1)[0]; - var planString = ""; + let planString = ""; if ( lastMessage && messageUtils.isPlanMessage(lastMessage.config.metadata) @@ -643,7 +648,7 @@ export default function ChatView({ const processedFiles = await convertFilesToBase64(files); // Send start message - var planString = plan ? convertPlanStepsToJsonString(plan.steps) : ""; + const planString = plan ? convertPlanStepsToJsonString(plan.steps) : ""; const taskJson = { content: query, diff --git a/frontend/src/components/views/chat/chatinput.tsx b/frontend/src/components/views/chat/chatinput.tsx index 80ad82be..445f8ced 100644 --- a/frontend/src/components/views/chat/chatinput.tsx +++ b/frontend/src/components/views/chat/chatinput.tsx @@ -63,13 +63,13 @@ const ChatInput = React.forwardRef<{ focus: () => void }, ChatInputProps>( onSubmit, error, disabled = false, - onCancel, + onCancel: _onCancel, runStatus, inputRequest, isPlanMessage = false, onPause, enable_upload = false, - onExecutePlan, + onExecutePlan: _onExecutePlan, }, ref ) => { @@ -88,7 +88,7 @@ const ChatInput = React.forwardRef<{ focus: () => void }, ChatInputProps>( const [relevantPlans, setRelevantPlans] = React.useState([]); const [allPlans, setAllPlans] = React.useState([]); const [attachedPlan, setAttachedPlan] = React.useState(null); - const [isLoading, setIsLoading] = React.useState(false); + const [, setIsLoading] = React.useState(false); const userId = user?.email || "default_user"; const [isRelevantPlansVisible, setIsRelevantPlansVisible] = React.useState(false); @@ -604,12 +604,13 @@ const ChatInput = React.forwardRef<{ focus: () => void }, ChatInputProps>( > {/* Attached Plan */} {attachedPlan && ( -
@@ -625,7 +626,7 @@ const ChatInput = React.forwardRef<{ focus: () => void }, ChatInputProps>( }} icon={} /> -
+ )} {/* Attached Files */} @@ -823,4 +824,6 @@ const ChatInput = React.forwardRef<{ focus: () => void }, ChatInputProps>( } ); +ChatInput.displayName = 'ChatInput'; + export default ChatInput; diff --git a/frontend/src/components/views/chat/detail_viewer.tsx b/frontend/src/components/views/chat/detail_viewer.tsx index c2bebfa6..06a58bfd 100644 --- a/frontend/src/components/views/chat/detail_viewer.tsx +++ b/frontend/src/components/views/chat/detail_viewer.tsx @@ -22,7 +22,7 @@ interface VncScreenProps { } // Lazy load the VNC component const VncScreen = lazy>(() => - // @ts-ignore + // @ts-expect-error - react-vnc module types are not available import("react-vnc").then((module) => ({ default: module.VncScreen })) ); @@ -45,6 +45,7 @@ interface DetailViewerProps { accepted?: boolean, plan?: IPlan ) => void; + viewMode?: "iframe" | "vnc"; } type TabType = "screenshots" | "live"; @@ -62,20 +63,19 @@ const DetailViewer: React.FC = ({ onTabChange, detailViewerContainerId, onInputResponse, + viewMode = "iframe", }) => { const [internalActiveTab, setInternalActiveTab] = useState("live"); const activeTab = controlledActiveTab ?? internalActiveTab; - const [viewMode, setViewMode] = useState<"iframe" | "novnc">("iframe"); const vncRef = useRef(); const [isModalOpen, setIsModalOpen] = useState(false); // Add state for fullscreen control mode const [isControlMode, setIsControlMode] = useState(false); - const browserIframeId = "browser-iframe-container"; // State for tracking if control was handed back from modal - const [showControlHandoverForm, setShowControlHandoverForm] = useState(false); + const [, setShowControlHandoverForm] = useState(false); const config = useSettingsStore((state) => state.config); @@ -214,8 +214,8 @@ const DetailViewer: React.FC = ({ ) : (
{}} // Moved overlay to BrowserIframe - onMouseLeave={() => {}} // Moved overlay to BrowserIframe + onMouseEnter={() => { }} // Moved overlay to BrowserIframe + onMouseLeave={() => { }} // Moved overlay to BrowserIframe > Loading VNC viewer...
}> = ({
)} diff --git a/frontend/src/components/views/chat/rendermessage.tsx b/frontend/src/components/views/chat/rendermessage.tsx index b5037811..bbc82d40 100644 --- a/frontend/src/components/views/chat/rendermessage.tsx +++ b/frontend/src/components/views/chat/rendermessage.tsx @@ -69,10 +69,10 @@ interface RenderStepExecutionProps { interface ParsedContent { text: - | string - | FunctionCall[] - | (string | ImageContent)[] - | FunctionExecutionResult[]; + | string + | FunctionCall[] + | (string | ImageContent)[] + | FunctionExecutionResult[]; metadata?: Record; plan?: IPlanStep[]; } @@ -188,7 +188,9 @@ const parseorchestratorContent = ( if (messageUtils.isStepExecution(metadata)) { return { type: "step-execution" as const, content: parsedContent }; } - } catch {} + } catch { + // Ignore JSON parsing errors and fall back to default type + } return { type: "default" as const, content }; }; @@ -225,12 +227,13 @@ const RenderMultiModalBrowserStep: React.FC<{ )} {/* Text content */} -
onImageClick?.(index)} > -
+
); @@ -238,6 +241,8 @@ const RenderMultiModalBrowserStep: React.FC<{
)); +RenderMultiModalBrowserStep.displayName = 'RenderMultiModalBrowserStep'; + const RenderMultiModal: React.FC<{ content: (string | ImageContent)[]; }> = memo(({ content }) => ( @@ -258,6 +263,8 @@ const RenderMultiModal: React.FC<{
)); +RenderMultiModal.displayName = 'RenderMultiModal'; + const RenderToolCall: React.FC<{ content: FunctionCall[] }> = memo( ({ content }) => (
@@ -274,6 +281,8 @@ const RenderToolCall: React.FC<{ content: FunctionCall[] }> = memo( ) ); +RenderToolCall.displayName = 'RenderToolCall'; + const RenderToolResult: React.FC<{ content: FunctionExecutionResult[] }> = memo( ({ content }) => { const [expandedResults, setExpandedResults] = useState<{ [key: string]: boolean }>({}); @@ -294,9 +303,14 @@ const RenderToolResult: React.FC<{ content: FunctionExecutionResult[] }> = memo( return (
Result ID: {result.call_id}
-
toggleExpand(result.call_id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') toggleExpand(result.call_id); + }} > {result.content.length > 100 && ( @@ -313,6 +327,8 @@ const RenderToolResult: React.FC<{ content: FunctionExecutionResult[] }> = memo( } ); +RenderToolResult.displayName = 'RenderToolResult'; + const RenderPlan: React.FC = memo( ({ content, isEditable, onSavePlan, onRegeneratePlan, forceCollapsed }) => { // Make sure content.steps is an array before using it @@ -346,6 +362,8 @@ const RenderPlan: React.FC = memo( } ); +RenderPlan.displayName = 'RenderPlan'; + const RenderStepExecution: React.FC = memo( ({ content, @@ -411,6 +429,14 @@ const RenderStepExecution: React.FC = memo(
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleToggle(); + } + }} + role="button" + tabIndex={0} >
@@ -659,7 +687,6 @@ export const RenderMessage: React.FC = memo( sessionId, messageIdx, runStatus, - isLast = false, className = "", isEditable = false, hidden = false, @@ -683,7 +710,7 @@ export const RenderMessage: React.FC = memo( ? parseUserContent(message) : { text: message.content, metadata: message.metadata }; - console.log(message.metadata) + console.log(message.metadata) // Use new plan message check const isPlanMsg = messageUtils.isPlanMessage(message.metadata); @@ -703,29 +730,25 @@ export const RenderMessage: React.FC = memo( return (