app/components/JobListItem.tsx (191 lines of code) (raw):
import { Job } from "~/types/job";
import { formatRelativeDate } from "~/lib/dateUtils";
import { AIProviderIcon } from "~/components/AIProviderIcons";
import { parseIssueMentions } from "~/lib/githubService";
import { KNOWN_ENVIRONMENTS } from "~/components/ConfigurationPanel";
import { JobEnvironmentViewer } from "~/components/JobEnvironmentViewer";
import { HuggingFaceIcon } from "~/components/icons";
import { useState } from "react";
// find the KNOWN_ENVIRONMENTS where the image matches the job's docker image from environment
export const getEnvironment = (job: Job) => {
// Look for Docker image in job environment or use a default detection method
const dockerImage =
job.environment?.DOCKER_IMAGE ||
(job.environment &&
Object.values(job.environment).find(
(val) =>
typeof val === "string" &&
val.includes(":") &&
(val.includes("drbh/") || val.includes("/"))
));
if (!dockerImage) return null;
console.log("getEnvironment", { job, dockerImage });
const env = KNOWN_ENVIRONMENTS.find((env) => env.image === dockerImage);
return env;
};
interface JobListItemProps {
job: Job;
onClick: (job: Job) => void;
}
const StatusBadge = ({ status }: { status: Job["status"] }) => {
const statusConfig = {
pending: {
color:
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400",
text: "Pending",
},
running: {
color: "bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400",
text: "Running",
},
completed: {
color:
"bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400",
text: "Completed",
},
failed: {
color: "bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400",
text: "Failed",
},
};
const config = statusConfig[status];
return (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${config.color}`}
>
{config.text}
</span>
);
};
export const JobListItem = ({ job, onClick }: JobListItemProps) => {
const [showEnvironmentViewer, setShowEnvironmentViewer] = useState(false);
const getChangesDisplay = () => {
if (!job.changes) return null;
const { additions, deletions } = job.changes;
return (
<div className="flex items-center gap-2 text-sm">
{additions > 0 && <span className="text-green-600">+{additions}</span>}
{deletions > 0 && <span className="text-red-600">−{deletions}</span>}
</div>
);
};
// Check if job has issue references
const issueMentions = parseIssueMentions(job.description);
const hasIssueReferences = issueMentions.length > 0;
return (
<div
className="cursor-pointer border-b border-gray-200 px-4 py-4 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-700/50"
onClick={() => onClick(job)}
>
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="mb-2 flex items-center gap-3">
<div className="flex items-center gap-2">
<AIProviderIcon
tags={job.tags}
className="text-gray-600 dark:text-gray-400"
size={16}
/>
<h3 className="truncate text-sm font-medium text-gray-900 dark:text-gray-100">
{job.title}
</h3>
{hasIssueReferences && (
<div className="flex items-center gap-1 rounded border border-blue-200 bg-blue-50 px-1.5 py-0.5 text-xs text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
<i className="fas fa-link text-xs"></i>
<span>
{issueMentions.length} issue
{issueMentions.length > 1 ? "s" : ""}
</span>
</div>
)}
</div>
<StatusBadge status={job.status} />
</div>
<p className="mb-2 overflow-hidden text-sm text-gray-600 dark:text-gray-400">
{job.description}
</p>
<div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-500">
{/* <span className="flex items-center gap-1">
<i className="fas fa-hashtag"></i>
{job.id.substring(0, 8)}
</span> */}
<span className="flex items-center gap-1">
<i className="fas fa-clock"></i>
{formatRelativeDate(job.createdAt)}
</span>
{/* {job.author && (
<span className="flex items-center gap-1">
<i className="fas fa-user"></i>
by {job.author}
</span>
)} */}
{job.repository?.url && (
<span className="flex max-w-32 items-center gap-1 truncate">
<i className="fas fa-folder"></i>
{job.repository.url.includes("github.com")
? job.repository.url.split("/").slice(-2).join("/")
: job.repository.url}
</span>
)}
{job.branch && (
<span className="flex items-center gap-1">
<i className="fas fa-code-branch"></i>
{job.branch}
</span>
)}
{job.tags && (
<span className="flex items-center gap-1">
<i className="fas fa-tag"></i>
{job.tags.join(", ")}
</span>
)}
{/* {job.environment && Object.keys(job.environment).length > 0 && (
<button
onClick={(e) => {
e.stopPropagation();
setShowEnvironmentViewer(true);
}}
className="flex items-center gap-1 text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 transition-colors"
title={`View environment details: ${Object.keys(job.environment).join(', ')}`}
>
<i className="fas fa-cog"></i>
{Object.keys(job.environment).length} env vars
</button>
)} */}
{job.environment && job.environment.LLM_MODEL && (
<span className="flex items-center gap-1">
<AIProviderIcon
tags={[
job.environment.LLM_PROVIDER === "openai"
? "chatgpt"
: job.environment.LLM_PROVIDER === "anthropic"
? "claude"
: job.environment.LLM_PROVIDER,
]}
className="text-gray-600 dark:text-gray-400"
size={16}
/>
{job.environment.LLM_MODEL}
</span>
)}
{job.apiJobId && (
<span
className="flex items-center gap-1"
title="Hugging Face API Job ID"
>
<HuggingFaceIcon
size={14}
className="text-gray-600 dark:text-gray-400"
/>
{job.apiJobId.substring(0, 8)}...
</span>
)}
{/* <span className="flex items-center gap-1" title="Container name">
<i className="fas fa-cube"></i>
hugex-job-{job.id.substring(0, 8)}
</span> */}
</div>
</div>
<div className="ml-4 flex items-center gap-3">
{getChangesDisplay()}
<i className="fas fa-chevron-right text-gray-400 dark:text-gray-500"></i>
</div>
</div>
</div>
);
};