app/routes/jobs.$jobId.tsx (1,203 lines of code) (raw):

import type { LoaderFunctionArgs, ActionFunctionArgs, MetaFunction, } from "@remix-run/node"; import { json, redirect } from "@remix-run/node"; import { useLoaderData, useNavigate, useFetcher } from "@remix-run/react"; import { useState, useEffect } from "react"; import { JobService } from "~/lib/jobService.remix"; import { DiffViewer } from "~/components/DiffViewer"; import { LogStream } from "~/components/LogStream"; import { Job, JobDiff } from "~/types/job"; import { formatFullDate } from "~/lib/dateUtils"; import { AuthWrapper } from "~/components/AuthWrapper"; import { extractCredentialsFromCookie, getEffectiveUsername, } from "~/lib/server/auth"; import serverConfig from "~/lib/server/config"; import { AIProviderIcon } from "~/components/AIProviderIcons"; import { parseIssueMentions } from "~/lib/githubService"; export { default as ErrorBoundary } from "~/components/ErrorBoundary"; export const meta: MetaFunction<typeof loader> = ({ data }) => { return [ { title: data?.job ? `${data.job.title} - Job Details` : "Job Not Found" }, { name: "description", content: "View job details and changes" }, ]; }; export const action = async ({ request, params }: ActionFunctionArgs) => { const jobId = params.jobId; if (!jobId) { throw new Response("Not Found", { status: 404 }); } const formData = await request.formData(); const intent = formData.get("intent"); if (intent === "create-pr") { const branch = formData.get("branch") as string; const title = formData.get("title") as string; const description = formData.get("description") as string; const baseBranch = formData.get("baseBranch") as string; try { // Get the authenticated user credentials const cookieHeader = request.headers.get("Cookie"); const credentials = extractCredentialsFromCookie(cookieHeader); // Get job and diff data const job = await JobService.getJob(jobId); const jobDiff = await JobService.getJobDiff(jobId); if (!job || !jobDiff || !job.repository?.url) { return json( { error: "Job, diff, or repository not found" }, { status: 400 } ); } if (!jobDiff.files || jobDiff.files.length === 0) { return json({ error: "No changes to commit" }, { status: 400 }); } // Create branch and push changes const result = await JobService.createBranchAndPush( jobId, { branch, title, description, baseBranch, }, credentials ); // Generate GitHub PR URL let prUrl = null; if (job.repository?.url && job.repository.url.includes("github.com")) { const repoUrl = job.repository.url.replace(/\.git$/, ""); prUrl = `${repoUrl}/compare/${branch}?expand=1`; } return json({ success: true, branch: result.branch, commitHash: result.commitHash, prUrl, message: `Successfully created branch '${branch}' and pushed changes. Commit: ${result.commitHash}`, }); } catch (error) { console.error("Failed to create branch and push:", error); return json( { error: `Failed to create branch: ${error.message}` }, { status: 500 } ); } } return json({ error: "Invalid action" }, { status: 400 }); }; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const jobId = params.jobId; if (!jobId) { throw new Response("Not Found", { status: 404 }); } // Get the authenticated user const cookieHeader = request.headers.get("Cookie"); const credentials = extractCredentialsFromCookie(cookieHeader); const username = getEffectiveUsername(credentials); console.log(`🔍 Job ${jobId} access check:`); console.log(`👤 Authenticated username: ${username}`); console.log(`🍪 Credentials available:`, { hasHfToken: !!credentials?.huggingfaceToken, hasGithubToken: !!credentials?.githubToken, hasHfUserInfo: !!credentials?.hfUserInfo, hasGithubUserInfo: !!credentials?.githubUserInfo, executionMode: serverConfig.EXECUTION_MODE, }); const job = await JobService.getJob(jobId); if (!job) { throw new Response("Not Found", { status: 404 }); } console.log(`📋 Job details:`); console.log(`📝 Job author: ${job.author}`); console.log(`🏷️ Job status: ${job.status}`); console.log(`🔐 Author match: ${job.author === username}`); // Check if the job belongs to the authenticated user // In Docker mode, allow access if user has any authentication (HF or GitHub) if ( job.author && job.author !== username && !( serverConfig.EXECUTION_MODE === "docker" && (credentials.huggingfaceToken || credentials.githubToken) ) ) { console.log( `❌ Access denied: Job author '${job.author}' !== authenticated user '${username}'` ); throw new Response("Unauthorized - This job belongs to another user", { status: 403, }); } console.log(`✅ Access granted for job ${jobId}`); const jobDiff = await JobService.getJobDiff(jobId); const jobLogs = await JobService.getJobLogs(jobId); return json({ job, jobDiff, jobLogs }); }; export default function JobDetail() { const { job, jobDiff, jobLogs } = useLoaderData<typeof loader>(); // Pulse animation styles const pulseStyle = { animation: "pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite", }; const navigate = useNavigate(); const fetcher = useFetcher<typeof action>(); // Set default tab based on job status - logs for running jobs, diff for completed ones const getDefaultTab = (status: Job["status"]): "diff" | "files" | "logs" => { if (status === "running" || status === "pending") { return "logs"; // Show logs for active jobs } return "diff"; // Show diff for completed/failed jobs }; const [activeTab, setActiveTab] = useState<"diff" | "files" | "logs">( getDefaultTab(job.status) ); const [userHasManuallyChangedTab, setUserHasManuallyChangedTab] = useState(false); const [refreshing, setRefreshing] = useState(false); const [lastRefresh, setLastRefresh] = useState<Date>(new Date()); const [showPRModal, setShowPRModal] = useState(false); const [prTitle, setPrTitle] = useState(``); const [prDescription, setPrDescription] = useState(``); const [prBranch, setPrBranch] = useState(`hugex-${job.id.substring(0, 8)}`); const [isHeaderExpanded, setIsHeaderExpanded] = useState(false); // Parse issue mentions from job description const issueMentions = parseIssueMentions(job.description); // Helper function to generate GitHub issue URL const getIssueUrl = (issueNumber: number): string | null => { if (!job.repository?.url || !job.repository.url.includes("github.com")) { return null; } const baseUrl = job.repository.url.replace(/\.git$/, "").replace(/\/$/, ""); return `${baseUrl}/issues/${issueNumber}`; }; // Auto-switch tabs when job status changes (only if user hasn't manually changed tabs) useEffect(() => { if (!userHasManuallyChangedTab) { const newDefaultTab = getDefaultTab(job.status); setActiveTab(newDefaultTab); } }, [job.status, userHasManuallyChangedTab]); // Create a wrapper function for tab changes to track manual changes const handleTabChange = (tab: "diff" | "files" | "logs") => { setActiveTab(tab); setUserHasManuallyChangedTab(true); }; // Auto-refresh for running jobs (smart refresh that doesn't interrupt log streaming) useEffect(() => { if (job.status === "running" || job.status === "pending") { const interval = setInterval(async () => { // If user is watching logs, use gentle background refresh instead of page reload if (activeTab === "logs") { console.log("Background refresh - user is watching live logs"); try { // Fetch just the job status without reloading the page const response = await fetch(`/api/jobs/${job.id}/status`); if (response.ok) { const statusData = await response.json(); console.log("Background status update:", statusData.status); // The LogStream component will handle status updates via SSE } } catch (error) { console.error("Background refresh failed:", error); } return; } // Full page refresh for other tabs setRefreshing(true); setTimeout(() => { window.location.reload(); }, 1000); }, 15000); // Refresh every 15 seconds return () => clearInterval(interval); } }, [job.status, activeTab, job.id]); // Add activeTab and job.id as dependencies // Handle fetcher response for PR creation useEffect(() => { if (fetcher.data && fetcher.state === "idle") { if (fetcher.data.error) { alert(`Error: ${fetcher.data.error}`); } else if (fetcher.data.success) { if (fetcher.data.message) { alert(fetcher.data.message); } // Open GitHub PR creation page if URL is available if (fetcher.data.prUrl) { window.open(fetcher.data.prUrl, "_blank"); } setShowPRModal(false); } } }, [fetcher.data, fetcher.state]); // Manual refresh function const handleRefresh = async () => { setRefreshing(true); setLastRefresh(new Date()); setTimeout(() => { window.location.reload(); }, 500); }; // Function to extract logs excluding the diff portion const getLogsWithoutDiff = (fullLogs: string): string => { if (!fullLogs) return ""; // console.log("Full logs:", fullLogs); const delimiter = "================================================================================"; const startIndex = fullLogs.indexOf(delimiter); console.log("Start index of delimiter:", startIndex); if (startIndex !== -1) { // Return everything before the first delimiter (which marks start of diff) const x = fullLogs.substring(0, startIndex).trim(); console.log(x); return x; } // If no delimiter found, return the full logs return fullLogs; }; // Check if logs were filtered (diff content was removed) const hasFilteredContent = (fullLogs: string): boolean => { if (!fullLogs) return false; const delimiter = "================================================================================"; return fullLogs.indexOf(delimiter) !== -1; }; // Function to parse and colorize terminal output const parseTerminalOutput = (text: string) => { const lines = text.split("\n"); return lines.map((line, index) => { let className = "text-gray-200"; // Default color let content = line; // // Error patterns (red) // if (line.match(/error|Error|ERROR|fail|Failed|FAILED|exception|Exception|panic/i)) { // className = 'text-red-400'; // } // // Warning patterns (yellow) // else if (line.match(/warn|Warning|WARNING|deprecated|DEPRECATED/i)) { // className = 'text-yellow-400'; // } // // Success patterns (green) // else if (line.match(/success|Success|SUCCESS|complete|Complete|COMPLETE|done|Done|DONE|✓|✔/i)) { // className = 'text-green-400'; // } // // File paths (cyan) // else if (line.match(/\.(js|ts|tsx|jsx|py|go|rs|java|cpp|c|h|css|html|json|yaml|yml|md|txt|log)\b/)) { // className = 'text-cyan-400'; // } // // URLs and HTTP (blue) // else if (line.match(/https?:\/\/|HTTP|GET|POST|PUT|DELETE|PATCH/)) { // className = 'text-blue-400'; // } // // Numbers and values (magenta) // else if (line.match(/^\s*\d+\s|\b\d+\.\d+\b|\b\d+%\b|\b\d+ms\b|\b\d+s\b/)) { // className = 'text-purple-400'; // } // // Commands and executables (bright blue) // else if (line.match(/^\$\s|^>\s|npm |yarn |pnpm |git |docker |node |python |pip |cargo |go |rustc /)) { // className = 'text-blue-300'; // } // // Timestamps (gray) // else if (line.match(/\d{4}-\d{2}-\d{2}|\d{2}:\d{2}:\d{2}|\[\d+\]/)) { // className = 'text-gray-400'; // } // // Comments and info (dim) // else if (line.match(/^#|^\/\/|^\s*\*|info|Info|INFO/i)) { // className = 'text-gray-400'; // } // get line type // const lineType = // const parsedLine = JSON.parse(line || "{}") // const lineType = parsedLine.type || "text"; // console.log("Parsed line:", parsedLine); const lineIsJson = line.startsWith("{") && line.endsWith("}"); if (lineIsJson) { try { const parsedLine = JSON.parse(line); // console.log("Parsed line:", parsedLine); // console.log("Parsed line:", parsedLine.type); const keys = Object.keys(parsedLine); // console.log("Parsed line keys:", keys); if (parsedLine.type) { // console.log(parsedLine.type); switch (parsedLine.type) { case "message": // console.log("Parsed message:", parsedLine); const concatText = parsedLine.content .map((t: string) => t.text) .join(" "); className = "text-blue-300"; content = concatText; // parsedLine.content; break; case "reasoning": className = "text-yellow-300"; content = parsedLine.summary; break; case "function_call": className = "text-green-300"; content = `${parsedLine.name}: ${parsedLine.arguments}`; break; case "function_call_output": className = "text-purple-300"; // console.log("Parsed function call output:", parsedLine); content = parsedLine.output; break; default: console.warn("Unknown line type:", parsedLine.type); className = "text-gray-200"; // Default for unknown types } } else { console.warn("Line does not have a type:", parsedLine); } // } catch (e) { console.error("Failed to parse line as JSON:", e); } } else { // console.log("Line is not JSON:", line); } return ( <div key={index} className={className}> {content} </div> ); }); }; const getStatusColor = (status: Job["status"]) => { switch (status) { case "completed": return "bg-green-100 text-green-800"; case "running": return "bg-blue-100 text-blue-800"; case "pending": return "bg-yellow-100 text-yellow-800"; case "failed": return "bg-red-100 text-red-800"; default: return "bg-gray-100 text-gray-800"; } }; const getStatusText = (status: Job["status"]) => { switch (status) { case "completed": return "Completed"; case "running": return "Running"; case "pending": return "Pending"; case "failed": return "Failed"; default: return "Unknown"; } }; return ( <AuthWrapper> <div className="min-h-screen"> <div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8"> {/* Page Header */} <div className="mb-6 flex items-center justify-between border-b border-gray-200 py-6 dark:border-gray-700"> <button onClick={() => navigate("/")} className="flex items-center gap-2 text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100" > <i className="fas fa-arrow-left"></i> <span>Back to Jobs</span> </button> <div className="flex items-center gap-4"> {(job.status === "running" || job.status === "pending") && ( <div className="flex items-center gap-2 rounded-full bg-blue-50 px-3 py-1.5 text-sm text-blue-600 dark:bg-blue-900/20 dark:text-blue-400"> <div className="h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500"></div> <span className="font-medium"> {activeTab === "logs" ? "Live streaming" : "Auto-refreshing"} </span> </div> )} <button onClick={handleRefresh} disabled={refreshing} className="rounded-lg p-2.5 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 disabled:opacity-50 dark:text-gray-400 dark:hover:bg-transparent dark:hover:text-gray-100" title="Refresh job status" > <i className={`fas fa-sync-alt ${ refreshing ? "animate-spin" : "" }`} ></i> </button> <button onClick={() => { setPrTitle(job.title); // Create comprehensive PR description with diff content let description = `${ job.description }\n\n---\n\n**Generated by HugeX Job ${job.id.substring( 0, 8 )}**\n\nThis PR contains changes generated automatically. Please review carefully before merging.\n\n`; // Add changes summary if available if (jobDiff && jobDiff.files.length > 0) { description += `## Changes Summary\n\n`; description += `- **Files changed:** ${jobDiff.summary.totalFiles}\n`; description += `- **Additions:** +${jobDiff.summary.totalAdditions}\n`; description += `- **Deletions:** -${jobDiff.summary.totalDeletions}\n\n`; description += `## Modified Files\n\n`; jobDiff.files.forEach((file) => { const statusEmoji = file.status === "added" ? "🆕" : file.status === "deleted" ? "🗑️" : file.status === "modified" ? "✏️" : "🔄"; description += `${statusEmoji} **${file.filename}** (${file.status})`; if (file.additions > 0 || file.deletions > 0) { description += ` (+${file.additions}/-${file.deletions})`; } description += `\n`; }); jobDiff.files.forEach((file) => { console.log(file); if (file.diff) { description += `### ${file.filename}\n\n`; description += `\`\`\`diff\n${file.diff}\n\`\`\`\n\n`; } }); } setPrDescription(description); setShowPRModal(true); }} disabled={ !jobDiff || jobDiff.files.length === 0 || !job.repository?.url } className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800" title={ !jobDiff || jobDiff.files.length === 0 ? "No changes available" : !job.repository?.url ? "No repository configured" : "Create pull request" } > <i className="fas fa-code-branch"></i> Create PR </button> <button onClick={() => { // Create and download patch file with diff data let patchContent = `# Patch for ${ job.title }\n# Generated on ${new Date().toISOString()}\n# Status: ${ job.status }\n# Repository: ${job.repository?.url || "N/A"}\n# Branch: ${ job.branch || "N/A" }\n# Author: ${job.author || "N/A"}\n\n## Description\n${ job.description }\n\n`; // Add diff information if available if (jobDiff && jobDiff.files.length > 0) { patchContent += `## Changes Summary\n`; patchContent += `Files changed: ${jobDiff.summary.totalFiles}\n`; patchContent += `Additions: +${jobDiff.summary.totalAdditions}\n`; patchContent += `Deletions: -${jobDiff.summary.totalDeletions}\n\n`; patchContent += `## Modified Files\n`; jobDiff.files.forEach((file) => { patchContent += `### ${file.filename} (${file.status})\n`; if (file.additions > 0) patchContent += `+${file.additions} `; if (file.deletions > 0) patchContent += `-${file.deletions} `; patchContent += `\n\n`; // Add the actual diff content if (file.diff) { patchContent += `\`\`\`diff\n${file.diff}\n\`\`\`\n\n`; } }); } else { patchContent += `## No diff data available\n`; patchContent += `This job may still be processing or no changes were generated.\n`; } const blob = new Blob([patchContent], { type: "text/plain" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `${job.title.replace( /[^a-zA-Z0-9]/g, "_" )}_patch.patch`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); }} className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800" > <i className="fas fa-download"></i> Download Patch </button> </div> </div> {/* Job Header */} <div className="mb-8 rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-800"> {/* Compact Header - Always Visible */} <div className="border-b border-gray-200 p-6 dark:border-gray-700"> <div className="flex items-center justify-between"> <div className="flex items-center gap-4"> <div className="flex items-center gap-3"> <AIProviderIcon tags={job.tags} className="text-gray-600 dark:text-gray-400" size={20} /> <h1 className="text-xl font-bold text-gray-900 dark:text-gray-100"> {job.title} </h1> </div> <span className={`inline-flex items-center rounded-full px-3 py-1 text-sm font-medium ${ job.status === "completed" ? "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400" : job.status === "running" ? "bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400" : job.status === "pending" ? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400" : "bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400" }`} > {getStatusText(job.status)} </span> {/* Quick Changes Summary */} {job.changes && ( <div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400"> <span className="text-green-600"> +{job.changes.additions} </span> <span className="text-red-600"> -{job.changes.deletions} </span> <span>{job.changes.files} files</span> </div> )} </div> <button onClick={() => setIsHeaderExpanded(!isHeaderExpanded)} className="flex items-center gap-2 text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" > <span className="text-sm font-medium"> {isHeaderExpanded ? "Less details" : "More details"} </span> <i className={`fas fa-chevron-down transition-transform ${ isHeaderExpanded ? "rotate-180" : "" }`} ></i> </button> </div> </div> {/* Expandable Detailed Content */} {isHeaderExpanded && ( <div className="border-b border-gray-200 p-8 dark:border-gray-700"> <p className="mb-6 text-base leading-relaxed text-gray-600 dark:text-gray-400"> {job.description} </p> {/* Job Information Tables */} <div className="space-y-4 lg:grid lg:grid-cols-2 lg:gap-8 lg:space-y-0"> {/* Primary Information Table */} <div className="overflow-hidden rounded-lg bg-gray-50 dark:bg-gray-700/30"> <table className="w-full"> <thead> <tr className="bg-gray-100 dark:bg-gray-700"> <th className="px-4 py-3 text-left text-sm font-semibold text-gray-700 dark:text-gray-300"> Property </th> <th className="px-4 py-3 text-left text-sm font-semibold text-gray-700 dark:text-gray-300"> Value </th> </tr> </thead> <tbody> <tr className=""> <td className="px-4 py-3 text-sm"> <div className="flex items-center gap-2"> <i className="fas fa-hashtag w-4 text-gray-400"></i> <span className="font-medium text-gray-600 dark:text-gray-400"> ID </span> </div> </td> <td className="px-4 py-3"> <span className="text-sm text-gray-900 dark:text-gray-100"> {job.id.substring(0, 8)}... </span> </td> </tr> <tr className=""> <td className="px-4 py-3 text-sm"> <div className="flex items-center gap-2"> <i className="fas fa-cube w-4 text-gray-400"></i> <span className="font-medium text-gray-600 dark:text-gray-400"> Container </span> </div> </td> <td className="px-4 py-3"> <span className="text-sm text-gray-900 dark:text-gray-100"> hugex-job-{job.id.substring(0, 8)} </span> </td> </tr> <tr className=""> <td className="px-4 py-3 text-sm"> <div className="flex items-center gap-2"> <i className="fas fa-clock w-4 text-gray-400"></i> <span className="font-medium text-gray-600 dark:text-gray-400"> Created </span> </div> </td> <td className="px-4 py-3"> <span className="text-sm text-gray-900 dark:text-gray-100"> {formatFullDate(job.createdAt)} </span> </td> </tr> <tr className=""> <td className="px-4 py-3 text-sm"> <div className="flex items-center gap-2"> <i className="fas fa-sync-alt w-4 text-gray-400"></i> <span className="font-medium text-gray-600 dark:text-gray-400"> Updated </span> </div> </td> <td className="px-4 py-3"> <span className="text-sm text-gray-900 dark:text-gray-100"> {formatFullDate(job.updatedAt)} </span> </td> </tr> </tbody> </table> </div> {/* Secondary Information Table */} {(job.author || job.repository?.url || job.branch) && ( <div className="overflow-hidden rounded-lg bg-gray-50 dark:bg-gray-700/30"> <table className="w-full"> <thead> <tr className="bg-gray-100 dark:bg-gray-700"> <th className="px-4 py-3 text-left text-sm font-semibold text-gray-700 dark:text-gray-300"> Property </th> <th className="px-4 py-3 text-left text-sm font-semibold text-gray-700 dark:text-gray-300"> Value </th> </tr> </thead> <tbody> {job.author && ( <tr className=""> <td className="px-4 py-3 text-sm"> <div className="flex items-center gap-2"> <i className="fas fa-user w-4 text-gray-400"></i> <span className="font-medium text-gray-600 dark:text-gray-400"> Author </span> </div> </td> <td className="px-4 py-3"> <span className="font-medium text-gray-600 dark:text-gray-100"> {job.author} </span> </td> </tr> )} {job.repository?.url && ( <tr className=""> <td className="px-4 py-3 text-sm"> <div className="flex items-center gap-2"> <i className="fas fa-folder w-4 text-gray-400"></i> <span className="font-medium text-gray-600 dark:text-gray-400"> Repository </span> </div> </td> <td className="px-4 py-3"> <a href={job.repository.url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-sm text-blue-600 underline decoration-dotted underline-offset-2 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" > <i className="fas fa-external-link-alt text-xs"></i> {job.repository.url.includes("github.com") ? job.repository.url .split("/") .slice(-2) .join("/") : job.repository.url} </a> </td> </tr> )} {job.branch && ( <tr className=""> <td className="px-4 py-3 text-sm"> <div className="flex items-center gap-2"> <i className="fas fa-code-branch w-4 text-gray-400"></i> <span className="font-medium text-gray-600 dark:text-gray-400"> Branch </span> </div> </td> <td className="px-4 py-3"> <span className="font-medium text-gray-600 dark:text-gray-100"> {job.branch} </span> </td> </tr> )} </tbody> </table> </div> )} {/* Environment Information Table */} {(job.environment && Object.keys(job.environment).length > 0) || (job.secrets && Object.keys(job.secrets).length > 0) ? ( <div className="overflow-hidden rounded-lg bg-gray-50 dark:bg-gray-700/30 lg:col-span-2"> <details className="group"> <summary className="flex cursor-pointer items-center justify-between bg-gray-100 px-4 py-3 text-sm font-semibold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"> <div className="flex items-center gap-2"> <i className="fas fa-cog w-4 text-gray-400"></i> <span>Environment & Configuration</span> <span className="ml-2 text-xs text-gray-500 dark:text-gray-400"> ( {(job.environment ? Object.keys(job.environment).length : 0) + (job.secrets ? Object.keys(job.secrets).length : 0)}{" "} items) </span> </div> <i className="fas fa-chevron-down text-gray-400 transition-transform group-open:rotate-180"></i> </summary> <div className="space-y-4 px-4 pb-4 pt-2"> {/* Environment Variables */} {job.environment && Object.keys(job.environment).length > 0 && ( <div> <h4 className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400"> <i className="fas fa-terminal text-xs text-gray-400"></i> Environment Variables ( {Object.keys(job.environment).length}) </h4> <div className="space-y-2"> {Object.entries(job.environment).map( ([key, value]) => ( <div key={key} className="flex flex-col gap-2 border-b border-gray-200 py-2 last:border-b-0 dark:border-gray-600 sm:flex-row sm:items-start" > <span className="min-w-0 flex-shrink-0 rounded bg-blue-50 px-2 py-1 font-mono text-sm font-medium text-blue-600 dark:bg-blue-900/20 dark:text-blue-400"> {key} </span> <span className="flex-1 break-all rounded bg-gray-100 px-2 py-1 font-mono text-sm text-gray-600 dark:bg-gray-600/30 dark:text-gray-300"> {value} </span> </div> ) )} </div> </div> )} {/* Secrets */} {job.secrets && Object.keys(job.secrets).length > 0 && ( <div> <h4 className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400"> <i className="fas fa-lock text-xs text-gray-400"></i> Secrets ({Object.keys(job.secrets).length}) </h4> <div className="space-y-2"> {Object.entries(job.secrets).map( ([key, value]) => ( <div key={key} className="flex flex-col gap-2 border-b border-gray-200 py-2 last:border-b-0 dark:border-gray-600 sm:flex-row sm:items-start" > <span className="min-w-0 flex-shrink-0 rounded bg-red-50 px-2 py-1 font-mono text-sm font-medium text-red-600 dark:bg-red-900/20 dark:text-red-400"> {key} </span> <span className="flex-1 rounded bg-gray-100 px-2 py-1 font-mono text-sm text-gray-600 dark:bg-gray-600/30 dark:text-gray-300"> {value} </span> </div> ) )} </div> <p className="mt-3 flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400"> <i className="fas fa-info-circle"></i> Secret values may be masked for security </p> </div> )} </div> </details> </div> ) : null} </div> </div> )} {(job.changes || issueMentions.length > 0) && ( <div className="border-t border-gray-200 bg-gray-50 px-8 py-6 dark:border-gray-700 dark:bg-gray-700/30"> <div className="flex items-center justify-between"> {/* Changes Summary */} {job.changes && ( <div className="flex items-center gap-8 text-sm"> <div className="flex items-center gap-3"> <i className="fas fa-chart-line text-gray-400"></i> <span className="font-medium text-gray-600 dark:text-gray-400"> Changes: </span> <div className="flex items-center gap-3"> <span className="rounded bg-green-50 px-2 py-1 font-semibold text-green-600 dark:bg-green-900/20"> +{job.changes.additions} </span> <span className="rounded bg-red-50 px-2 py-1 font-semibold text-red-600 dark:bg-red-900/20"> −{job.changes.deletions} </span> </div> </div> <div className="flex items-center gap-3"> <i className="fas fa-file-alt text-gray-400"></i> <span className="font-medium text-gray-600 dark:text-gray-400"> Files: </span> <span className="rounded bg-gray-100 px-2 py-1 font-semibold text-gray-900 dark:bg-transparent dark:text-gray-100"> {job.changes.files} </span> </div> </div> )} {/* Referenced Issues Pills */} {issueMentions.length > 0 && ( <div className="flex items-center gap-2"> <span className="text-xs font-medium text-gray-500 dark:text-gray-400"> Issues: </span> <div className="flex items-center gap-1.5"> {issueMentions.map((mention, index) => { const issueUrl = getIssueUrl(mention.number); return issueUrl ? ( <a key={index} href={issueUrl} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-1 text-xs text-blue-700 transition-colors hover:bg-blue-200 dark:bg-blue-800 dark:text-blue-200 dark:hover:bg-blue-700" title={`View issue #${mention.number}`} > <i className="fas fa-hashtag text-xs"></i> {mention.number} <i className="fas fa-external-link-alt text-xs opacity-60"></i> </a> ) : ( <span key={index} className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-1 text-xs text-gray-600 dark:bg-transparent dark:text-gray-400" title={`Issue #${mention.number} (repository not linked)`} > <i className="fas fa-hashtag text-xs"></i> {mention.number} </span> ); })} </div> </div> )} </div> </div> )} </div> {/* Content Tabs */} <div className="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-800"> <div className="border-b border-gray-200 dark:border-gray-700"> <nav className="flex"> <button onClick={() => handleTabChange("diff")} className={`border-b-2 px-6 py-4 text-sm font-medium transition-colors ${ activeTab === "diff" ? "border-blue-500 text-blue-600 dark:text-blue-400" : "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-300" }`} > <span className="flex items-center gap-2"> Diff {(job.status === "completed" || job.status === "failed") && !userHasManuallyChangedTab && ( <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500" title="Recommended for completed jobs" ></span> )} </span> </button> <button onClick={() => handleTabChange("files")} className={`border-b-2 px-6 py-4 text-sm font-medium transition-colors ${ activeTab === "files" ? "border-blue-500 text-blue-600 dark:text-blue-400" : "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-300" }`} > Files </button> <button onClick={() => handleTabChange("logs")} className={`border-b-2 px-6 py-4 text-sm font-medium transition-colors ${ activeTab === "logs" ? "border-blue-500 text-blue-600 dark:text-blue-400" : "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-300" }`} > <span className="flex items-center gap-2"> Logs {(job.status === "running" || job.status === "pending") && !userHasManuallyChangedTab && ( <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500" title="Recommended for running jobs" ></span> )} </span> </button> </nav> </div> <div className="p-8"> {activeTab === "diff" && ( <div> {jobDiff ? ( <DiffViewer files={jobDiff.files} /> ) : ( <div className="py-12 text-center text-gray-500 dark:text-gray-400"> <div className="mb-4"> <i className="fas fa-file-alt text-6xl text-gray-300 dark:text-gray-600"></i> </div> <p className="mb-2 text-lg font-medium" style={pulseStyle} > No changes available </p> <p className="text-sm" style={pulseStyle}> {job.status === "pending" ? "Job is pending - changes will appear when processing starts" : job.status === "running" ? "Job is running - changes will appear when completed" : job.status === "failed" ? "Job failed - no changes were generated" : "This job completed without generating a diff"} </p> </div> )} </div> )} {activeTab === "files" && ( <div> {jobDiff && jobDiff.files.length > 0 ? ( <div className="space-y-4"> <div className="mb-4 text-sm text-gray-600 dark:text-gray-400"> {jobDiff.summary.totalFiles} files changed,{" "} <span className="text-green-600"> {jobDiff.summary.totalAdditions} additions </span> ,{" "} <span className="text-red-600"> {jobDiff.summary.totalDeletions} deletions </span> </div> <div className="divide-y divide-gray-200 dark:divide-gray-700"> {jobDiff.files.map((file, index) => ( <div key={index} className="py-4 first:pt-0 last:pb-0" > <div className="flex items-center justify-between"> <div className="flex items-center gap-3"> <div className={`h-2 w-2 rounded-full ${ file.status === "added" ? "bg-green-500" : file.status === "deleted" ? "bg-red-500" : file.status === "modified" ? "bg-blue-500" : "bg-purple-500" }`} ></div> <span className="font-mono text-sm text-gray-900 dark:text-gray-100"> {file.filename} </span> {file.status === "renamed" && file.oldFilename && ( <span className="text-xs text-gray-500 dark:text-gray-400"> (renamed from {file.oldFilename}) </span> )} </div> <div className="flex items-center gap-3 text-sm"> {file.additions > 0 && ( <span className="text-green-600"> +{file.additions} </span> )} {file.deletions > 0 && ( <span className="text-red-600"> −{file.deletions} </span> )} </div> </div> </div> ))} </div> </div> ) : ( <div className="py-12 text-center text-gray-500 dark:text-gray-400"> <p style={pulseStyle}>No files to display</p> </div> )} </div> )} {activeTab === "logs" && ( <div> {job.status === "running" || job.status === "pending" ? ( // Use real-time streaming for active jobs <LogStream jobId={job.id} className="rounded-lg border border-gray-200 dark:border-gray-700" /> ) : jobLogs ? ( // Show static logs for completed/failed jobs <div className="overflow-hidden rounded-lg bg-gray-900"> <div className="flex items-center justify-between border-b border-gray-700 bg-gray-800 px-4 py-2"> <div className="flex items-center gap-2"> <i className="fas fa-terminal text-green-400"></i> <span className="text-sm font-medium text-gray-300"> Execution Logs </span> {hasFilteredContent(jobLogs) && ( <span className="rounded bg-yellow-400/10 px-2 py-1 text-xs text-yellow-400"> Diff excluded </span> )} </div> <button onClick={() => { const logContent = getLogsWithoutDiff(jobLogs); navigator.clipboard.writeText(logContent); }} className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-200" title="Copy logs to clipboard" > <i className="fas fa-copy"></i> Copy </button> </div> <div className="max-h-[600px] overflow-auto p-4"> <div className="whitespace-pre-wrap break-words font-mono text-sm leading-relaxed"> {parseTerminalOutput(getLogsWithoutDiff(jobLogs))} </div> </div> </div> ) : ( <div className="py-12 text-center text-gray-500 dark:text-gray-400"> <div className="mb-4"> <i className="fas fa-terminal text-6xl text-gray-300 dark:text-gray-600"></i> </div> <p className="mb-2 text-lg font-medium" style={pulseStyle} > No logs available </p> <p className="text-sm" style={pulseStyle}> {job.status === "pending" ? "Job is pending - logs will appear when processing starts" : job.status === "running" ? "Job is running - logs will appear as processing continues" : job.status === "failed" ? "Job failed - check if any logs were generated before failure" : "This job completed without generating logs"} </p> </div> )} </div> )} </div> </div> </div> {/* PR Creation Modal */} {showPRModal && ( <div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4"> <div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800"> <div className="border-b border-gray-200 p-6 dark:border-gray-700"> <div className="flex items-center justify-between"> <h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100"> Create Pull Request </h2> <button onClick={() => setShowPRModal(false)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" > <i className="fas fa-times"></i> </button> </div> </div> <div className="space-y-6 p-6"> <div> <label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> Branch Name </label> <input type="text" value={prBranch} onChange={(e) => setPrBranch(e.target.value)} className="w-full rounded-md border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-transparent dark:text-gray-100" placeholder="feature/my-changes" /> </div> <div> <label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> Pull Request Title </label> <input type="text" value={prTitle} onChange={(e) => setPrTitle(e.target.value)} className="w-full rounded-md border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-transparent dark:text-gray-100" placeholder="Enter PR title" /> </div> <div> <label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> Description </label> <textarea value={prDescription} onChange={(e) => setPrDescription(e.target.value)} rows={8} className="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-transparent dark:text-gray-100" placeholder="Describe the changes in this PR" /> </div> {jobDiff && ( <div className="rounded-lg bg-gray-50 p-4 dark:bg-gray-700/30"> <h3 className="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300"> Changes Summary </h3> <div className="text-sm text-gray-600 dark:text-gray-400"> <div className="mb-2 flex items-center gap-4"> <span>{jobDiff.summary.totalFiles} files changed</span> <span className="text-green-600"> +{jobDiff.summary.totalAdditions} additions </span> <span className="text-red-600"> -{jobDiff.summary.totalDeletions} deletions </span> </div> <div className="space-y-1"> {jobDiff.files.slice(0, 5).map((file, index) => ( <div key={index} className="flex items-center gap-2"> <div className={`h-2 w-2 rounded-full ${ file.status === "added" ? "bg-green-500" : file.status === "deleted" ? "bg-red-500" : file.status === "modified" ? "bg-blue-500" : "bg-purple-500" }`} ></div> <span className="font-mono text-xs"> {file.filename} </span> </div> ))} {jobDiff.files.length > 5 && ( <div className="text-xs text-gray-500"> ...and {jobDiff.files.length - 5} more files </div> )} </div> </div> </div> )} </div> <div className="flex items-center justify-end gap-3 border-t border-gray-200 p-6 dark:border-gray-700"> <button onClick={() => setShowPRModal(false)} className="rounded-lg px-4 py-2 text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-transparent" > Cancel </button> <button onClick={() => { const formData = new FormData(); formData.append("intent", "create-pr"); formData.append("branch", prBranch); formData.append("title", prTitle); formData.append("description", prDescription); formData.append("baseBranch", job.branch || "main"); fetcher.submit(formData, { method: "post" }); }} disabled={ !prTitle.trim() || !prBranch.trim() || fetcher.state === "submitting" } className="flex items-center gap-2 rounded-lg border border-gray-300 px-6 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800" > <i className={`fas ${ fetcher.state === "submitting" ? "fa-spinner fa-spin" : "fa-code-branch" }`} ></i> {fetcher.state === "submitting" ? "Creating Branch..." : "Create Branch & Push"} </button> </div> </div> </div> )} </div> </AuthWrapper> ); }