mirror of
https://github.com/coleam00/Archon.git
synced 2025-12-24 02:39:17 -05:00
Refactor the UI is working, work in progress. Zustand next to work better with SSE.
This commit is contained in:
@@ -24,7 +24,7 @@ import { useMigrationStatus } from './hooks/useMigrationStatus';
|
||||
|
||||
|
||||
const AppRoutes = () => {
|
||||
const { projectsEnabled, styleGuideEnabled } = useSettings();
|
||||
const { projectsEnabled, styleGuideEnabled, agentWorkOrdersEnabled } = useSettings();
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
@@ -45,8 +45,14 @@ const AppRoutes = () => {
|
||||
) : (
|
||||
<Route path="/projects" element={<Navigate to="/" replace />} />
|
||||
)}
|
||||
<Route path="/agent-work-orders" element={<AgentWorkOrdersPage />} />
|
||||
<Route path="/agent-work-orders/:id" element={<AgentWorkOrderDetailPage />} />
|
||||
{agentWorkOrdersEnabled ? (
|
||||
<>
|
||||
<Route path="/agent-work-orders" element={<AgentWorkOrdersPage />} />
|
||||
<Route path="/agent-work-orders/:id" element={<AgentWorkOrderDetailPage />} />
|
||||
</>
|
||||
) : (
|
||||
<Route path="/agent-work-orders" element={<Navigate to="/" replace />} />
|
||||
)}
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookOpen, Bot, Palette, Settings } from "lucide-react";
|
||||
import { BookOpen, Bot, Palette, Settings, TestTube } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
// TEMPORARY: Use old SettingsContext until settings are migrated
|
||||
@@ -24,7 +24,7 @@ interface NavigationProps {
|
||||
*/
|
||||
export function Navigation({ className }: NavigationProps) {
|
||||
const location = useLocation();
|
||||
const { projectsEnabled, styleGuideEnabled } = useSettings();
|
||||
const { projectsEnabled, styleGuideEnabled, agentWorkOrdersEnabled } = useSettings();
|
||||
|
||||
// Navigation items configuration
|
||||
const navigationItems: NavigationItem[] = [
|
||||
@@ -38,7 +38,7 @@ export function Navigation({ className }: NavigationProps) {
|
||||
path: "/agent-work-orders",
|
||||
icon: <Bot className="h-5 w-5" />,
|
||||
label: "Agent Work Orders",
|
||||
enabled: true,
|
||||
enabled: agentWorkOrdersEnabled,
|
||||
},
|
||||
{
|
||||
path: "/mcp",
|
||||
|
||||
@@ -14,10 +14,16 @@ export const FeaturesSection = () => {
|
||||
setTheme
|
||||
} = useTheme();
|
||||
const { showToast } = useToast();
|
||||
const { styleGuideEnabled, setStyleGuideEnabled: setStyleGuideContext } = useSettings();
|
||||
const {
|
||||
styleGuideEnabled,
|
||||
setStyleGuideEnabled: setStyleGuideContext,
|
||||
agentWorkOrdersEnabled,
|
||||
setAgentWorkOrdersEnabled: setAgentWorkOrdersContext
|
||||
} = useSettings();
|
||||
const isDarkMode = theme === 'dark';
|
||||
const [projectsEnabled, setProjectsEnabled] = useState(true);
|
||||
const [styleGuideEnabledLocal, setStyleGuideEnabledLocal] = useState(styleGuideEnabled);
|
||||
const [agentWorkOrdersEnabledLocal, setAgentWorkOrdersEnabledLocal] = useState(agentWorkOrdersEnabled);
|
||||
|
||||
// Commented out for future release
|
||||
const [agUILibraryEnabled, setAgUILibraryEnabled] = useState(false);
|
||||
@@ -38,6 +44,10 @@ export const FeaturesSection = () => {
|
||||
setStyleGuideEnabledLocal(styleGuideEnabled);
|
||||
}, [styleGuideEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
setAgentWorkOrdersEnabledLocal(agentWorkOrdersEnabled);
|
||||
}, [agentWorkOrdersEnabled]);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -224,6 +234,29 @@ export const FeaturesSection = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAgentWorkOrdersToggle = async (checked: boolean) => {
|
||||
if (loading) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setAgentWorkOrdersEnabledLocal(checked);
|
||||
|
||||
// Update context which will save to backend
|
||||
await setAgentWorkOrdersContext(checked);
|
||||
|
||||
showToast(
|
||||
checked ? 'Agent Work Orders Enabled' : 'Agent Work Orders Disabled',
|
||||
checked ? 'success' : 'warning'
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to update agent work orders setting:', error);
|
||||
setAgentWorkOrdersEnabledLocal(!checked);
|
||||
showToast('Failed to update agent work orders setting', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
@@ -298,6 +331,28 @@ export const FeaturesSection = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Work Orders Toggle */}
|
||||
<div className="flex items-center gap-4 p-4 rounded-xl bg-gradient-to-br from-green-500/10 to-green-600/5 backdrop-blur-sm border border-green-500/20 shadow-lg">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-800 dark:text-white">
|
||||
Agent Work Orders
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Enable automated development workflows with Claude Code CLI
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Switch
|
||||
size="lg"
|
||||
checked={agentWorkOrdersEnabledLocal}
|
||||
onCheckedChange={handleAgentWorkOrdersToggle}
|
||||
color="green"
|
||||
icon={<Bot className="w-5 h-5" />}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* COMMENTED OUT FOR FUTURE RELEASE - AG-UI Library Toggle */}
|
||||
{/*
|
||||
<div className="flex items-center gap-4 p-4 rounded-xl bg-gradient-to-br from-pink-500/10 to-pink-600/5 backdrop-blur-sm border border-pink-500/20 shadow-lg">
|
||||
|
||||
@@ -6,6 +6,8 @@ interface SettingsContextType {
|
||||
setProjectsEnabled: (enabled: boolean) => Promise<void>;
|
||||
styleGuideEnabled: boolean;
|
||||
setStyleGuideEnabled: (enabled: boolean) => Promise<void>;
|
||||
agentWorkOrdersEnabled: boolean;
|
||||
setAgentWorkOrdersEnabled: (enabled: boolean) => Promise<void>;
|
||||
loading: boolean;
|
||||
refreshSettings: () => Promise<void>;
|
||||
}
|
||||
@@ -27,16 +29,18 @@ interface SettingsProviderProps {
|
||||
export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children }) => {
|
||||
const [projectsEnabled, setProjectsEnabledState] = useState(true);
|
||||
const [styleGuideEnabled, setStyleGuideEnabledState] = useState(false);
|
||||
const [agentWorkOrdersEnabled, setAgentWorkOrdersEnabledState] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Load Projects and Style Guide settings
|
||||
const [projectsResponse, styleGuideResponse] = await Promise.all([
|
||||
// Load Projects, Style Guide, and Agent Work Orders settings
|
||||
const [projectsResponse, styleGuideResponse, agentWorkOrdersResponse] = await Promise.all([
|
||||
credentialsService.getCredential('PROJECTS_ENABLED').catch(() => ({ value: undefined })),
|
||||
credentialsService.getCredential('STYLE_GUIDE_ENABLED').catch(() => ({ value: undefined }))
|
||||
credentialsService.getCredential('STYLE_GUIDE_ENABLED').catch(() => ({ value: undefined })),
|
||||
credentialsService.getCredential('AGENT_WORK_ORDERS_ENABLED').catch(() => ({ value: undefined }))
|
||||
]);
|
||||
|
||||
if (projectsResponse.value !== undefined) {
|
||||
@@ -51,10 +55,17 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children })
|
||||
setStyleGuideEnabledState(false); // Default to false
|
||||
}
|
||||
|
||||
if (agentWorkOrdersResponse.value !== undefined) {
|
||||
setAgentWorkOrdersEnabledState(agentWorkOrdersResponse.value === 'true');
|
||||
} else {
|
||||
setAgentWorkOrdersEnabledState(false); // Default to false
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
setProjectsEnabledState(true);
|
||||
setStyleGuideEnabledState(false);
|
||||
setAgentWorkOrdersEnabledState(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -106,6 +117,27 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children })
|
||||
}
|
||||
};
|
||||
|
||||
const setAgentWorkOrdersEnabled = async (enabled: boolean) => {
|
||||
try {
|
||||
// Update local state immediately
|
||||
setAgentWorkOrdersEnabledState(enabled);
|
||||
|
||||
// Save to backend
|
||||
await credentialsService.createCredential({
|
||||
key: 'AGENT_WORK_ORDERS_ENABLED',
|
||||
value: enabled.toString(),
|
||||
is_encrypted: false,
|
||||
category: 'features',
|
||||
description: 'Enable Agent Work Orders feature for automated development workflows'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update agent work orders setting:', error);
|
||||
// Revert on error
|
||||
setAgentWorkOrdersEnabledState(!enabled);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshSettings = async () => {
|
||||
await loadSettings();
|
||||
};
|
||||
@@ -115,6 +147,8 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children })
|
||||
setProjectsEnabled,
|
||||
styleGuideEnabled,
|
||||
setStyleGuideEnabled,
|
||||
agentWorkOrdersEnabled,
|
||||
setAgentWorkOrdersEnabled,
|
||||
loading,
|
||||
refreshSettings
|
||||
};
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Add Repository Modal Component
|
||||
*
|
||||
* Modal for adding new configured repositories with GitHub verification.
|
||||
* Two-column layout: Left (2/3) for form fields, Right (1/3) for workflow steps.
|
||||
*/
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/features/ui/primitives/button";
|
||||
import { Checkbox } from "@/features/ui/primitives/checkbox";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/features/ui/primitives/dialog";
|
||||
import { Input } from "@/features/ui/primitives/input";
|
||||
import { Label } from "@/features/ui/primitives/label";
|
||||
import { useCreateRepository } from "../hooks/useRepositoryQueries";
|
||||
import type { WorkflowStep } from "../types";
|
||||
|
||||
export interface AddRepositoryModalProps {
|
||||
/** Whether modal is open */
|
||||
open: boolean;
|
||||
|
||||
/** Callback to change open state */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* All available workflow steps
|
||||
*/
|
||||
const WORKFLOW_STEPS: { value: WorkflowStep; label: string; description: string; dependsOn?: WorkflowStep[] }[] = [
|
||||
{ value: "create-branch", label: "Create Branch", description: "Create a new git branch for isolated work" },
|
||||
{ value: "planning", label: "Planning", description: "Generate implementation plan" },
|
||||
{ value: "execute", label: "Execute", description: "Implement the planned changes" },
|
||||
{ value: "commit", label: "Commit", description: "Commit changes to git", dependsOn: ["execute"] },
|
||||
{ value: "create-pr", label: "Create PR", description: "Create pull request", dependsOn: ["execute"] },
|
||||
{ value: "prp-review", label: "PRP Review", description: "Review against PRP document" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Default selected steps for new repositories
|
||||
*/
|
||||
const DEFAULT_STEPS: WorkflowStep[] = ["create-branch", "planning", "execute"];
|
||||
|
||||
export function AddRepositoryModal({ open, onOpenChange }: AddRepositoryModalProps) {
|
||||
const [repositoryUrl, setRepositoryUrl] = useState("");
|
||||
const [selectedSteps, setSelectedSteps] = useState<WorkflowStep[]>(DEFAULT_STEPS);
|
||||
const [error, setError] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const createRepository = useCreateRepository();
|
||||
|
||||
/**
|
||||
* Reset form state
|
||||
*/
|
||||
const resetForm = () => {
|
||||
setRepositoryUrl("");
|
||||
setSelectedSteps(DEFAULT_STEPS);
|
||||
setError("");
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle workflow step selection
|
||||
*/
|
||||
const toggleStep = (step: WorkflowStep) => {
|
||||
setSelectedSteps((prev) => {
|
||||
if (prev.includes(step)) {
|
||||
return prev.filter((s) => s !== step);
|
||||
}
|
||||
return [...prev, step];
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a step is disabled based on dependencies
|
||||
*/
|
||||
const isStepDisabled = (step: typeof WORKFLOW_STEPS[number]): boolean => {
|
||||
if (!step.dependsOn) return false;
|
||||
return step.dependsOn.some((dep) => !selectedSteps.includes(dep));
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle form submission
|
||||
*/
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
// Validation
|
||||
if (!repositoryUrl.trim()) {
|
||||
setError("Repository URL is required");
|
||||
return;
|
||||
}
|
||||
if (!repositoryUrl.includes("github.com")) {
|
||||
setError("Must be a GitHub repository URL");
|
||||
return;
|
||||
}
|
||||
if (selectedSteps.length === 0) {
|
||||
setError("At least one workflow step must be selected");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await createRepository.mutateAsync({
|
||||
repository_url: repositoryUrl,
|
||||
verify: true,
|
||||
});
|
||||
|
||||
// Success - close modal and reset form
|
||||
resetForm();
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create repository");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Repository</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="pt-4">
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{/* Left Column (2/3 width) - Form Fields */}
|
||||
<div className="col-span-2 space-y-4">
|
||||
{/* Repository URL */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="repository-url">Repository URL *</Label>
|
||||
<Input
|
||||
id="repository-url"
|
||||
type="url"
|
||||
placeholder="https://github.com/owner/repository"
|
||||
value={repositoryUrl}
|
||||
onChange={(e) => setRepositoryUrl(e.target.value)}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
GitHub repository URL. We'll verify access and extract metadata automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info about auto-filled fields */}
|
||||
<div className="p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">
|
||||
<strong>Auto-filled from GitHub:</strong>
|
||||
</p>
|
||||
<ul className="text-xs text-gray-600 dark:text-gray-400 mt-1 space-y-0.5 ml-4 list-disc">
|
||||
<li>Display Name (can be customized later via Edit)</li>
|
||||
<li>Owner/Organization</li>
|
||||
<li>Default Branch</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column (1/3 width) - Workflow Steps */}
|
||||
<div className="space-y-4">
|
||||
<Label>Default Workflow Steps</Label>
|
||||
<div className="space-y-2">
|
||||
{WORKFLOW_STEPS.map((step) => {
|
||||
const isSelected = selectedSteps.includes(step.value);
|
||||
const isDisabled = isStepDisabled(step);
|
||||
|
||||
return (
|
||||
<div key={step.value} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`step-${step.value}`}
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => !isDisabled && toggleStep(step.value)}
|
||||
disabled={isDisabled}
|
||||
aria-label={step.label}
|
||||
/>
|
||||
<Label htmlFor={`step-${step.value}`} className={isDisabled ? "text-gray-400" : ""}>
|
||||
{step.label}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Commit and PR require Execute</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mt-4 text-sm text-red-600 dark:text-red-400 bg-red-500/10 border border-red-500/30 rounded p-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-6 mt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting} variant="cyan">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" aria-hidden="true" />
|
||||
Adding...
|
||||
</>
|
||||
) : (
|
||||
"Add Repository"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
/**
|
||||
* CreateWorkOrderDialog Component
|
||||
*
|
||||
* Modal dialog for creating new agent work orders with form validation.
|
||||
* Includes repository URL, sandbox type, user request, and command selection.
|
||||
*/
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useId, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/features/ui/primitives/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/features/ui/primitives/dialog";
|
||||
import { useCreateWorkOrder } from "../hooks/useAgentWorkOrderQueries";
|
||||
import type { WorkflowStep } from "../types";
|
||||
|
||||
const workOrderSchema = z.object({
|
||||
repository_url: z.string().url("Must be a valid URL"),
|
||||
sandbox_type: z.enum(["git_branch", "git_worktree"]),
|
||||
user_request: z.string().min(10, "Request must be at least 10 characters"),
|
||||
github_issue_number: z.string().optional(),
|
||||
});
|
||||
|
||||
type WorkOrderFormData = z.infer<typeof workOrderSchema>;
|
||||
|
||||
interface CreateWorkOrderDialogProps {
|
||||
/** Whether dialog is open */
|
||||
open: boolean;
|
||||
/** Callback when dialog should close */
|
||||
onClose: () => void;
|
||||
/** Callback when work order is created */
|
||||
onSuccess?: (workOrderId: string) => void;
|
||||
}
|
||||
|
||||
const ALL_COMMANDS: WorkflowStep[] = ["create-branch", "planning", "execute", "commit", "create-pr"];
|
||||
|
||||
const COMMAND_LABELS: Record<WorkflowStep, string> = {
|
||||
"create-branch": "Create Branch",
|
||||
planning: "Planning",
|
||||
execute: "Execute",
|
||||
commit: "Commit",
|
||||
"create-pr": "Create PR",
|
||||
"prp-review": "PRP Review",
|
||||
};
|
||||
|
||||
export function CreateWorkOrderDialog({ open, onClose, onSuccess }: CreateWorkOrderDialogProps) {
|
||||
const [selectedCommands, setSelectedCommands] = useState<WorkflowStep[]>(ALL_COMMANDS);
|
||||
const createWorkOrder = useCreateWorkOrder();
|
||||
const formId = useId();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
reset,
|
||||
} = useForm<WorkOrderFormData>({
|
||||
resolver: zodResolver(workOrderSchema),
|
||||
defaultValues: {
|
||||
sandbox_type: "git_branch",
|
||||
},
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
reset();
|
||||
setSelectedCommands(ALL_COMMANDS);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const onSubmit = async (data: WorkOrderFormData) => {
|
||||
createWorkOrder.mutate(
|
||||
{
|
||||
...data,
|
||||
selected_commands: selectedCommands,
|
||||
github_issue_number: data.github_issue_number || null,
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
handleClose();
|
||||
onSuccess?.(result.agent_work_order_id);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const toggleCommand = (command: WorkflowStep) => {
|
||||
setSelectedCommands((prev) => (prev.includes(command) ? prev.filter((c) => c !== command) : [...prev, command]));
|
||||
};
|
||||
|
||||
const setPreset = (preset: "full" | "planning" | "no-pr") => {
|
||||
switch (preset) {
|
||||
case "full":
|
||||
setSelectedCommands(ALL_COMMANDS);
|
||||
break;
|
||||
case "planning":
|
||||
setSelectedCommands(["create-branch", "planning"]);
|
||||
break;
|
||||
case "no-pr":
|
||||
setSelectedCommands(["create-branch", "planning", "execute", "commit"]);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Agent Work Order</DialogTitle>
|
||||
<DialogDescription>Configure and launch a new AI-driven development workflow</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor={`${formId}-repository_url`} className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Repository URL *
|
||||
</label>
|
||||
<input
|
||||
id={`${formId}-repository_url`}
|
||||
type="text"
|
||||
{...register("repository_url")}
|
||||
placeholder="https://github.com/username/repo"
|
||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
{errors.repository_url && <p className="mt-1 text-sm text-red-400">{errors.repository_url.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor={`${formId}-sandbox_type`} className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Sandbox Type *
|
||||
</label>
|
||||
<select
|
||||
id={`${formId}-sandbox_type`}
|
||||
{...register("sandbox_type")}
|
||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="git_branch">Git Branch</option>
|
||||
<option value="git_worktree">Git Worktree</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor={`${formId}-user_request`} className="block text-sm font-medium text-gray-300 mb-2">
|
||||
User Request *
|
||||
</label>
|
||||
<textarea
|
||||
id={`${formId}-user_request`}
|
||||
{...register("user_request")}
|
||||
rows={4}
|
||||
placeholder="Describe the work you want the AI agent to perform..."
|
||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500 resize-none"
|
||||
/>
|
||||
{errors.user_request && <p className="mt-1 text-sm text-red-400">{errors.user_request.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor={`${formId}-github_issue_number`} className="block text-sm font-medium text-gray-300 mb-2">
|
||||
GitHub Issue Number (optional)
|
||||
</label>
|
||||
<input
|
||||
id={`${formId}-github_issue_number`}
|
||||
type="text"
|
||||
{...register("github_issue_number")}
|
||||
placeholder="123"
|
||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="block text-sm font-medium text-gray-300">Workflow Commands</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreset("full")}
|
||||
className="text-xs px-2 py-1 bg-gray-700 text-gray-300 rounded hover:bg-gray-600"
|
||||
>
|
||||
Full
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreset("planning")}
|
||||
className="text-xs px-2 py-1 bg-gray-700 text-gray-300 rounded hover:bg-gray-600"
|
||||
>
|
||||
Planning Only
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreset("no-pr")}
|
||||
className="text-xs px-2 py-1 bg-gray-700 text-gray-300 rounded hover:bg-gray-600"
|
||||
>
|
||||
No PR
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{ALL_COMMANDS.map((command) => (
|
||||
<label
|
||||
key={command}
|
||||
className="flex items-center gap-3 p-3 bg-gray-800 border border-gray-700 rounded-lg hover:border-gray-600 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCommands.includes(command)}
|
||||
onChange={() => toggleCommand(command)}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-gray-300">{COMMAND_LABELS[command]}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={handleClose} disabled={createWorkOrder.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createWorkOrder.isPending || selectedCommands.length === 0}>
|
||||
{createWorkOrder.isPending ? "Creating..." : "Create Work Order"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
{createWorkOrder.isError && (
|
||||
<div className="mt-4 p-3 bg-red-900 bg-opacity-30 border border-red-700 rounded text-sm text-red-300">
|
||||
Failed to create work order. Please try again.
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Create Work Order Modal Component
|
||||
*
|
||||
* Two-column modal for creating work orders with improved layout.
|
||||
* Left column (2/3): Form fields for repository, request, issue
|
||||
* Right column (1/3): Workflow steps selection
|
||||
*/
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/features/ui/primitives/button";
|
||||
import { Checkbox } from "@/features/ui/primitives/checkbox";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/features/ui/primitives/dialog";
|
||||
import { Input, TextArea } from "@/features/ui/primitives/input";
|
||||
import { Label } from "@/features/ui/primitives/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/features/ui/primitives/select";
|
||||
import { useCreateWorkOrder } from "../hooks/useAgentWorkOrderQueries";
|
||||
import { useRepositories } from "../hooks/useRepositoryQueries";
|
||||
import type { SandboxType, WorkflowStep } from "../types";
|
||||
|
||||
export interface CreateWorkOrderModalProps {
|
||||
/** Whether modal is open */
|
||||
open: boolean;
|
||||
|
||||
/** Callback to change open state */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
|
||||
/** Pre-selected repository ID */
|
||||
selectedRepositoryId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* All available workflow steps with dependency info
|
||||
*/
|
||||
const WORKFLOW_STEPS: { value: WorkflowStep; label: string; dependsOn?: WorkflowStep[] }[] = [
|
||||
{ value: "create-branch", label: "Create Branch" },
|
||||
{ value: "planning", label: "Planning" },
|
||||
{ value: "execute", label: "Execute" },
|
||||
{ value: "commit", label: "Commit Changes", dependsOn: ["execute"] },
|
||||
{ value: "create-pr", label: "Create Pull Request", dependsOn: ["execute"] },
|
||||
{ value: "prp-review", label: "PRP Review" },
|
||||
];
|
||||
|
||||
export function CreateWorkOrderModal({ open, onOpenChange, selectedRepositoryId }: CreateWorkOrderModalProps) {
|
||||
const { data: repositories = [] } = useRepositories();
|
||||
const createWorkOrder = useCreateWorkOrder();
|
||||
|
||||
const [repositoryId, setRepositoryId] = useState(selectedRepositoryId || "");
|
||||
const [repositoryUrl, setRepositoryUrl] = useState("");
|
||||
const [sandboxType, setSandboxType] = useState<SandboxType>("git_worktree");
|
||||
const [userRequest, setUserRequest] = useState("");
|
||||
const [githubIssueNumber, setGithubIssueNumber] = useState("");
|
||||
const [selectedCommands, setSelectedCommands] = useState<WorkflowStep[]>(["create-branch", "planning", "execute"]);
|
||||
const [error, setError] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
/**
|
||||
* Pre-populate form when repository is selected
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (selectedRepositoryId) {
|
||||
setRepositoryId(selectedRepositoryId);
|
||||
const repo = repositories.find((r) => r.id === selectedRepositoryId);
|
||||
if (repo) {
|
||||
setRepositoryUrl(repo.repository_url);
|
||||
setSandboxType(repo.default_sandbox_type);
|
||||
setSelectedCommands(repo.default_commands as WorkflowStep[]);
|
||||
}
|
||||
}
|
||||
}, [selectedRepositoryId, repositories]);
|
||||
|
||||
/**
|
||||
* Handle repository selection change
|
||||
*/
|
||||
const handleRepositoryChange = (newRepositoryId: string) => {
|
||||
setRepositoryId(newRepositoryId);
|
||||
const repo = repositories.find((r) => r.id === newRepositoryId);
|
||||
if (repo) {
|
||||
setRepositoryUrl(repo.repository_url);
|
||||
setSandboxType(repo.default_sandbox_type);
|
||||
setSelectedCommands(repo.default_commands as WorkflowStep[]);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle workflow step selection
|
||||
*/
|
||||
const toggleStep = (step: WorkflowStep) => {
|
||||
setSelectedCommands((prev) => {
|
||||
if (prev.includes(step)) {
|
||||
return prev.filter((s) => s !== step);
|
||||
}
|
||||
return [...prev, step];
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a step is disabled based on dependencies
|
||||
*/
|
||||
const isStepDisabled = (step: typeof WORKFLOW_STEPS[number]): boolean => {
|
||||
if (!step.dependsOn) return false;
|
||||
return step.dependsOn.some((dep) => !selectedCommands.includes(dep));
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset form state
|
||||
*/
|
||||
const resetForm = () => {
|
||||
setRepositoryId(selectedRepositoryId || "");
|
||||
setRepositoryUrl("");
|
||||
setSandboxType("git_worktree");
|
||||
setUserRequest("");
|
||||
setGithubIssueNumber("");
|
||||
setSelectedCommands(["create-branch", "planning", "execute"]);
|
||||
setError("");
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle form submission
|
||||
*/
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
// Validation
|
||||
if (!repositoryUrl.trim()) {
|
||||
setError("Repository URL is required");
|
||||
return;
|
||||
}
|
||||
if (userRequest.trim().length < 10) {
|
||||
setError("Request must be at least 10 characters");
|
||||
return;
|
||||
}
|
||||
if (selectedCommands.length === 0) {
|
||||
setError("At least one workflow step must be selected");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await createWorkOrder.mutateAsync({
|
||||
repository_url: repositoryUrl,
|
||||
sandbox_type: sandboxType,
|
||||
user_request: userRequest,
|
||||
github_issue_number: githubIssueNumber || undefined,
|
||||
selected_commands: selectedCommands,
|
||||
});
|
||||
|
||||
// Success - close modal and reset
|
||||
resetForm();
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create work order");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Work Order</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="pt-4">
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{/* Left Column (2/3 width) - Form Fields */}
|
||||
<div className="col-span-2 space-y-4">
|
||||
{/* Repository Selector */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="repository">Repository</Label>
|
||||
<Select value={repositoryId} onValueChange={handleRepositoryChange}>
|
||||
<SelectTrigger id="repository" aria-label="Select repository">
|
||||
<SelectValue placeholder="Select a repository..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{repositories.map((repo) => (
|
||||
<SelectItem key={repo.id} value={repo.id}>
|
||||
{repo.display_name || repo.repository_url}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* User Request */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-request">Work Request</Label>
|
||||
<TextArea
|
||||
id="user-request"
|
||||
placeholder="Describe the work you want the agent to perform..."
|
||||
rows={4}
|
||||
value={userRequest}
|
||||
onChange={(e) => setUserRequest(e.target.value)}
|
||||
aria-invalid={!!error && userRequest.length < 10}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Minimum 10 characters</p>
|
||||
</div>
|
||||
|
||||
{/* GitHub Issue Number (optional) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="github-issue">GitHub Issue Number (Optional)</Label>
|
||||
<Input
|
||||
id="github-issue"
|
||||
type="text"
|
||||
placeholder="e.g., 42"
|
||||
value={githubIssueNumber}
|
||||
onChange={(e) => setGithubIssueNumber(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sandbox Type */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sandbox-type">Sandbox Type</Label>
|
||||
<Select value={sandboxType} onValueChange={(value) => setSandboxType(value as SandboxType)}>
|
||||
<SelectTrigger id="sandbox-type" aria-label="Select sandbox type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="git_worktree">Git Worktree (Recommended)</SelectItem>
|
||||
<SelectItem value="git_branch">Git Branch</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column (1/3 width) - Workflow Steps */}
|
||||
<div className="space-y-4">
|
||||
<Label>Workflow Steps</Label>
|
||||
<div className="space-y-2">
|
||||
{WORKFLOW_STEPS.map((step) => {
|
||||
const isSelected = selectedCommands.includes(step.value);
|
||||
const isDisabled = isStepDisabled(step);
|
||||
|
||||
return (
|
||||
<div key={step.value} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`step-${step.value}`}
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => !isDisabled && toggleStep(step.value)}
|
||||
disabled={isDisabled}
|
||||
aria-label={step.label}
|
||||
/>
|
||||
<Label htmlFor={`step-${step.value}`} className={isDisabled ? "text-gray-400" : ""}>
|
||||
{step.label}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Commit and PR require Execute</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mt-4 text-sm text-red-600 dark:text-red-400 bg-red-500/10 border border-red-500/30 rounded p-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-6 mt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting} variant="cyan">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" aria-hidden="true" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
"Create Work Order"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Edit Repository Modal Component
|
||||
*
|
||||
* Modal for editing configured repository settings.
|
||||
* Two-column layout: Left (2/3) for form fields, Right (1/3) for workflow steps.
|
||||
*/
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/features/ui/primitives/button";
|
||||
import { Checkbox } from "@/features/ui/primitives/checkbox";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/features/ui/primitives/dialog";
|
||||
import { Label } from "@/features/ui/primitives/label";
|
||||
import { useUpdateRepository } from "../hooks/useRepositoryQueries";
|
||||
import type { ConfiguredRepository } from "../types/repository";
|
||||
import type { WorkflowStep } from "../types";
|
||||
|
||||
export interface EditRepositoryModalProps {
|
||||
/** Whether modal is open */
|
||||
open: boolean;
|
||||
|
||||
/** Callback to change open state */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
|
||||
/** Repository to edit */
|
||||
repository: ConfiguredRepository | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* All available workflow steps
|
||||
*/
|
||||
const WORKFLOW_STEPS: { value: WorkflowStep; label: string; description: string; dependsOn?: WorkflowStep[] }[] = [
|
||||
{ value: "create-branch", label: "Create Branch", description: "Create a new git branch for isolated work" },
|
||||
{ value: "planning", label: "Planning", description: "Generate implementation plan" },
|
||||
{ value: "execute", label: "Execute", description: "Implement the planned changes" },
|
||||
{ value: "commit", label: "Commit", description: "Commit changes to git", dependsOn: ["execute"] },
|
||||
{ value: "create-pr", label: "Create PR", description: "Create pull request", dependsOn: ["execute"] },
|
||||
{ value: "prp-review", label: "PRP Review", description: "Review against PRP document" },
|
||||
];
|
||||
|
||||
export function EditRepositoryModal({ open, onOpenChange, repository }: EditRepositoryModalProps) {
|
||||
const [selectedSteps, setSelectedSteps] = useState<WorkflowStep[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const updateRepository = useUpdateRepository();
|
||||
|
||||
/**
|
||||
* Pre-populate form when repository changes
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (repository) {
|
||||
setSelectedSteps(repository.default_commands);
|
||||
}
|
||||
}, [repository]);
|
||||
|
||||
/**
|
||||
* Toggle workflow step selection
|
||||
*/
|
||||
const toggleStep = (step: WorkflowStep) => {
|
||||
setSelectedSteps((prev) => {
|
||||
if (prev.includes(step)) {
|
||||
return prev.filter((s) => s !== step);
|
||||
}
|
||||
return [...prev, step];
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a step is disabled based on dependencies
|
||||
*/
|
||||
const isStepDisabled = (step: typeof WORKFLOW_STEPS[number]): boolean => {
|
||||
if (!step.dependsOn) return false;
|
||||
return step.dependsOn.some((dep) => !selectedSteps.includes(dep));
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle form submission
|
||||
*/
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!repository) return;
|
||||
|
||||
setError("");
|
||||
|
||||
// Validation
|
||||
if (selectedSteps.length === 0) {
|
||||
setError("At least one workflow step must be selected");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await updateRepository.mutateAsync({
|
||||
id: repository.id,
|
||||
request: {
|
||||
default_sandbox_type: repository.default_sandbox_type,
|
||||
default_commands: selectedSteps,
|
||||
},
|
||||
});
|
||||
|
||||
// Success - close modal
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to update repository");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!repository) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Repository</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="pt-4">
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{/* Left Column (2/3 width) - Repository Info */}
|
||||
<div className="col-span-2 space-y-4">
|
||||
{/* Repository Info Card */}
|
||||
<div className="p-4 bg-gray-500/10 border border-gray-500/20 rounded-lg space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">Repository Information</h4>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">URL: </span>
|
||||
<span className="text-gray-900 dark:text-white font-mono text-xs">{repository.repository_url}</span>
|
||||
</div>
|
||||
|
||||
{repository.display_name && (
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Name: </span>
|
||||
<span className="text-gray-900 dark:text-white">{repository.display_name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{repository.owner && (
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Owner: </span>
|
||||
<span className="text-gray-900 dark:text-white">{repository.owner}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{repository.default_branch && (
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Branch: </span>
|
||||
<span className="text-gray-900 dark:text-white font-mono text-xs">{repository.default_branch}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
Repository metadata is auto-filled from GitHub and cannot be edited directly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column (1/3 width) - Workflow Steps */}
|
||||
<div className="space-y-4">
|
||||
<Label>Default Workflow Steps</Label>
|
||||
<div className="space-y-2">
|
||||
{WORKFLOW_STEPS.map((step) => {
|
||||
const isSelected = selectedSteps.includes(step.value);
|
||||
const isDisabled = isStepDisabled(step);
|
||||
|
||||
return (
|
||||
<div key={step.value} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`edit-step-${step.value}`}
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => !isDisabled && toggleStep(step.value)}
|
||||
disabled={isDisabled}
|
||||
aria-label={step.label}
|
||||
/>
|
||||
<Label htmlFor={`edit-step-${step.value}`} className={isDisabled ? "text-gray-400" : ""}>
|
||||
{step.label}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Commit and PR require Execute</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mt-4 text-sm text-red-600 dark:text-red-400 bg-red-500/10 border border-red-500/30 rounded p-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-6 mt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting} variant="cyan">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" aria-hidden="true" />
|
||||
Updating...
|
||||
</>
|
||||
) : (
|
||||
"Save Changes"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/features/ui/primitives/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/features/ui/primitives/select";
|
||||
import { cn } from "@/features/ui/primitives/styles";
|
||||
import { Switch } from "@/features/ui/primitives/switch";
|
||||
import type { LogEntry } from "../types";
|
||||
|
||||
interface ExecutionLogsProps {
|
||||
/** Real logs from SSE stream */
|
||||
logs: LogEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color class for log level badge - STATIC lookup
|
||||
*/
|
||||
const logLevelColors: Record<string, string> = {
|
||||
info: "bg-blue-500/20 text-blue-600 dark:text-blue-400 border-blue-400/30",
|
||||
warning: "bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 border-yellow-400/30",
|
||||
error: "bg-red-500/20 text-red-600 dark:text-red-400 border-red-400/30",
|
||||
debug: "bg-gray-500/20 text-gray-600 dark:text-gray-400 border-gray-400/30",
|
||||
};
|
||||
|
||||
/**
|
||||
* Format timestamp to relative time
|
||||
*/
|
||||
function formatRelativeTime(timestamp: string): string {
|
||||
const now = Date.now();
|
||||
const logTime = new Date(timestamp).getTime();
|
||||
const diffSeconds = Math.floor((now - logTime) / 1000);
|
||||
|
||||
if (diffSeconds < 60) return `${diffSeconds}s ago`;
|
||||
if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)}m ago`;
|
||||
return `${Math.floor(diffSeconds / 3600)}h ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual log entry component
|
||||
*/
|
||||
function LogEntryRow({ log }: { log: LogEntry }) {
|
||||
const colorClass = logLevelColors[log.level] || logLevelColors.debug;
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2 py-1 px-2 hover:bg-white/5 dark:hover:bg-black/20 rounded font-mono text-sm">
|
||||
<span className="text-gray-500 dark:text-gray-400 text-xs whitespace-nowrap">
|
||||
{formatRelativeTime(log.timestamp)}
|
||||
</span>
|
||||
<span className={cn("px-1.5 py-0.5 rounded text-xs border uppercase whitespace-nowrap", colorClass)}>
|
||||
{log.level}
|
||||
</span>
|
||||
{log.step && <span className="text-cyan-600 dark:text-cyan-400 text-xs whitespace-nowrap">[{log.step}]</span>}
|
||||
<span className="text-gray-900 dark:text-gray-300 flex-1">{log.event}</span>
|
||||
{log.progress && (
|
||||
<span className="text-gray-500 dark:text-gray-400 text-xs whitespace-nowrap">{log.progress}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExecutionLogs({ logs }: ExecutionLogsProps) {
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [levelFilter, setLevelFilter] = useState<string>("all");
|
||||
|
||||
// Filter logs by level
|
||||
const filteredLogs = levelFilter === "all" ? logs : logs.filter((log) => log.level === levelFilter);
|
||||
|
||||
return (
|
||||
<div className="border border-white/10 dark:border-gray-700/30 rounded-lg overflow-hidden bg-black/20 dark:bg-white/5 backdrop-blur">
|
||||
{/* Header with controls */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10 dark:border-gray-700/30 bg-gray-900/50 dark:bg-gray-800/30">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-300">Execution Logs</span>
|
||||
|
||||
{/* Live indicator */}
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
<span className="text-xs text-green-600 dark:text-green-400">Live</span>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">({filteredLogs.length} entries)</span>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Level filter using proper Select primitive */}
|
||||
<Select value={levelFilter} onValueChange={setLevelFilter}>
|
||||
<SelectTrigger className="w-32 h-8 text-xs" aria-label="Filter log level">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Levels</SelectItem>
|
||||
<SelectItem value="info">Info</SelectItem>
|
||||
<SelectItem value="warning">Warning</SelectItem>
|
||||
<SelectItem value="error">Error</SelectItem>
|
||||
<SelectItem value="debug">Debug</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Auto-scroll toggle using Switch primitive */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="auto-scroll-toggle" className="text-xs text-gray-700 dark:text-gray-300">
|
||||
Auto-scroll:
|
||||
</label>
|
||||
<Switch
|
||||
id="auto-scroll-toggle"
|
||||
checked={autoScroll}
|
||||
onCheckedChange={setAutoScroll}
|
||||
aria-label="Toggle auto-scroll"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
autoScroll ? "text-cyan-600 dark:text-cyan-400" : "text-gray-500 dark:text-gray-400",
|
||||
)}
|
||||
>
|
||||
{autoScroll ? "ON" : "OFF"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Clear logs button */}
|
||||
<Button variant="ghost" size="xs" aria-label="Clear logs">
|
||||
<Trash2 className="w-3 h-3" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log content - scrollable area */}
|
||||
<div className="max-h-96 overflow-y-auto bg-black/40 dark:bg-black/20">
|
||||
{filteredLogs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<p>No logs match the current filter</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
{filteredLogs.map((log, index) => (
|
||||
<LogEntryRow key={`${log.timestamp}-${index}`} log={log} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,7 @@
|
||||
/**
|
||||
* RealTimeStats Component
|
||||
*
|
||||
* Displays real-time execution statistics derived from log stream.
|
||||
* Shows current step, progress percentage, elapsed time, and current activity.
|
||||
*/
|
||||
|
||||
import { Activity, Clock, TrendingUp } from "lucide-react";
|
||||
import { Activity, ChevronDown, ChevronUp, Clock, TrendingUp } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/features/ui/primitives/button";
|
||||
import { ExecutionLogs } from "./ExecutionLogs";
|
||||
import { useLogStats } from "../hooks/useLogStats";
|
||||
import { useWorkOrderLogs } from "../hooks/useWorkOrderLogs";
|
||||
|
||||
@@ -32,21 +27,10 @@ function formatDuration(seconds: number): string {
|
||||
return `${secs}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time from ISO timestamp
|
||||
*/
|
||||
function formatRelativeTime(timestamp: string): string {
|
||||
const now = new Date().getTime();
|
||||
const logTime = new Date(timestamp).getTime();
|
||||
const diffSeconds = Math.floor((now - logTime) / 1000);
|
||||
|
||||
if (diffSeconds < 1) return "just now";
|
||||
if (diffSeconds < 60) return `${diffSeconds}s ago`;
|
||||
if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)}m ago`;
|
||||
return `${Math.floor(diffSeconds / 3600)}h ago`;
|
||||
}
|
||||
|
||||
export function RealTimeStats({ workOrderId }: RealTimeStatsProps) {
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
|
||||
// Real SSE data
|
||||
const { logs } = useWorkOrderLogs({ workOrderId, autoReconnect: true });
|
||||
const stats = useLogStats(logs);
|
||||
|
||||
@@ -79,98 +63,108 @@ export function RealTimeStats({ workOrderId }: RealTimeStatsProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentStep = stats.currentStep || "initializing";
|
||||
const stepDisplay =
|
||||
stats.currentStepNumber !== null && stats.totalSteps !== null
|
||||
? `(${stats.currentStepNumber}/${stats.totalSteps})`
|
||||
: "";
|
||||
const progressPct = stats.progressPct || 0;
|
||||
const elapsedSeconds = currentElapsedSeconds !== null ? currentElapsedSeconds : stats.elapsedSeconds || 0;
|
||||
const currentActivity = stats.currentActivity || "Initializing workflow...";
|
||||
|
||||
return (
|
||||
<div className="border border-white/10 rounded-lg p-4 bg-black/20 backdrop-blur">
|
||||
<h3 className="text-sm font-semibold text-gray-300 mb-3 flex items-center gap-2">
|
||||
<Activity className="w-4 h-4" />
|
||||
Real-Time Execution
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="border border-white/10 dark:border-gray-700/30 rounded-lg p-4 bg-black/20 dark:bg-white/5 backdrop-blur">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-300 mb-3 flex items-center gap-2">
|
||||
<Activity className="w-4 h-4" aria-hidden="true" />
|
||||
Real-Time Execution
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Current Step */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide">Current Step</div>
|
||||
<div className="text-sm font-medium text-gray-200">
|
||||
{stats.currentStep || "Initializing..."}
|
||||
{stats.currentStepNumber !== null && stats.totalSteps !== null && (
|
||||
<span className="text-gray-500 ml-2">
|
||||
({stats.currentStepNumber}/{stats.totalSteps})
|
||||
</span>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Current Step */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Current Step</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">
|
||||
{currentStep}
|
||||
{stepDisplay && <span className="text-gray-500 dark:text-gray-400 ml-2">{stepDisplay}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide flex items-center gap-1">
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
Progress
|
||||
</div>
|
||||
{stats.progressPct !== null ? (
|
||||
{/* Progress */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide flex items-center gap-1">
|
||||
<TrendingUp className="w-3 h-3" aria-hidden="true" />
|
||||
Progress
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div className="flex-1 h-2 bg-gray-700 dark:bg-gray-200/20 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-cyan-500 to-blue-500 transition-all duration-500 ease-out"
|
||||
style={{ width: `${stats.progressPct}%` }}
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-cyan-400">{stats.progressPct}%</span>
|
||||
<span className="text-sm font-medium text-cyan-600 dark:text-cyan-400">{progressPct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500">Calculating...</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Elapsed Time */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
Elapsed Time
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-200">
|
||||
{currentElapsedSeconds !== null ? formatDuration(currentElapsedSeconds) : "0s"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Activity */}
|
||||
{stats.currentActivity && (
|
||||
<div className="mt-4 pt-3 border-t border-white/10">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide whitespace-nowrap">Latest Activity:</div>
|
||||
<div className="text-sm text-gray-300 flex-1">
|
||||
{stats.currentActivity}
|
||||
{stats.lastActivity && (
|
||||
<span className="text-gray-500 ml-2 text-xs">{formatRelativeTime(stats.lastActivity)}</span>
|
||||
)}
|
||||
{/* Elapsed Time */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" aria-hidden="true" />
|
||||
Elapsed Time
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">
|
||||
{formatDuration(elapsedSeconds)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Indicators */}
|
||||
<div className="mt-3 flex items-center gap-4 text-xs">
|
||||
{stats.hasCompleted && (
|
||||
<div className="flex items-center gap-1 text-green-400">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full" />
|
||||
<span>Completed</span>
|
||||
{/* Latest Activity with Status Indicator - at top */}
|
||||
<div className="mt-4 pt-3 border-t border-white/10 dark:border-gray-700/30">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide whitespace-nowrap">
|
||||
Latest Activity:
|
||||
</div>
|
||||
<div className="text-sm text-gray-900 dark:text-gray-300 flex-1 truncate">{currentActivity}</div>
|
||||
</div>
|
||||
{/* Status Indicator - right side of Latest Activity */}
|
||||
<div className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 flex-shrink-0">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
||||
<span>Running</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{stats.hasFailed && (
|
||||
<div className="flex items-center gap-1 text-red-400">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full" />
|
||||
<span>Failed</span>
|
||||
</div>
|
||||
)}
|
||||
{!stats.hasCompleted && !stats.hasFailed && stats.hasStarted && (
|
||||
<div className="flex items-center gap-1 text-blue-400">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
||||
<span>Running</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Show Execution Logs button - at bottom */}
|
||||
<div className="mt-3 pt-3 border-t border-white/10 dark:border-gray-700/30">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowLogs(!showLogs)}
|
||||
className="w-full justify-center text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/10"
|
||||
aria-label={showLogs ? "Hide execution logs" : "Show execution logs"}
|
||||
aria-expanded={showLogs}
|
||||
>
|
||||
{showLogs ? (
|
||||
<>
|
||||
<ChevronUp className="w-4 h-4 mr-1" aria-hidden="true" />
|
||||
Hide Execution Logs
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="w-4 h-4 mr-1" aria-hidden="true" />
|
||||
Show Execution Logs
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Collapsible Execution Logs */}
|
||||
{showLogs && <ExecutionLogs logs={logs} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Repository Card Component
|
||||
*
|
||||
* Displays a configured repository with custom stat pills matching the example layout.
|
||||
* Uses SelectableCard primitive with glassmorphism styling.
|
||||
*/
|
||||
|
||||
import { Activity, CheckCircle2, Clock, Copy, Edit, Trash2 } from "lucide-react";
|
||||
import { SelectableCard } from "@/features/ui/primitives/selectable-card";
|
||||
import { cn } from "@/features/ui/primitives/styles";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/features/ui/primitives/tooltip";
|
||||
import type { ConfiguredRepository } from "../types/repository";
|
||||
|
||||
export interface RepositoryCardProps {
|
||||
/** Repository data to display */
|
||||
repository: ConfiguredRepository;
|
||||
|
||||
/** Whether this repository is currently selected */
|
||||
isSelected?: boolean;
|
||||
|
||||
/** Whether to show aurora glow effect (when selected) */
|
||||
showAuroraGlow?: boolean;
|
||||
|
||||
/** Callback when repository is selected */
|
||||
onSelect?: () => void;
|
||||
|
||||
/** Callback when edit button is clicked */
|
||||
onEdit?: () => void;
|
||||
|
||||
/** Callback when delete button is clicked */
|
||||
onDelete?: () => void;
|
||||
|
||||
/** Work order statistics for this repository */
|
||||
stats?: {
|
||||
total: number;
|
||||
active: number;
|
||||
done: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get background class based on card state
|
||||
*/
|
||||
function getBackgroundClass(isSelected: boolean): string {
|
||||
if (isSelected) {
|
||||
return "bg-gradient-to-b from-white/70 via-purple-50/20 to-white/50 dark:from-white/5 dark:via-purple-900/5 dark:to-black/20";
|
||||
}
|
||||
return "bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30";
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to clipboard
|
||||
*/
|
||||
async function copyToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function RepositoryCard({
|
||||
repository,
|
||||
isSelected = false,
|
||||
showAuroraGlow = false,
|
||||
onSelect,
|
||||
onEdit,
|
||||
onDelete,
|
||||
stats = { total: 0, active: 0, done: 0 },
|
||||
}: RepositoryCardProps) {
|
||||
const backgroundClass = getBackgroundClass(isSelected);
|
||||
|
||||
const handleCopyUrl = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const success = await copyToClipboard(repository.repository_url);
|
||||
if (success) {
|
||||
// Could add toast notification here
|
||||
console.log("Repository URL copied to clipboard");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (onEdit) {
|
||||
onEdit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (onDelete) {
|
||||
onDelete();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectableCard
|
||||
isSelected={isSelected}
|
||||
isPinned={false}
|
||||
showAuroraGlow={showAuroraGlow}
|
||||
onSelect={onSelect}
|
||||
size="none"
|
||||
blur="xl"
|
||||
className={cn("w-72 min-h-[180px] flex flex-col shrink-0", backgroundClass)}
|
||||
>
|
||||
{/* Main content */}
|
||||
<div className="flex-1 p-3 pb-2">
|
||||
{/* Title */}
|
||||
<div className="flex flex-col items-center justify-center mb-4 min-h-[48px]">
|
||||
<h3
|
||||
className={cn(
|
||||
"font-medium text-center leading-tight line-clamp-2 transition-all duration-300",
|
||||
isSelected
|
||||
? "text-gray-900 dark:text-white drop-shadow-[0_0_8px_rgba(255,255,255,0.8)]"
|
||||
: "text-gray-500 dark:text-gray-400",
|
||||
)}
|
||||
>
|
||||
{repository.display_name || repository.repository_url.replace("https://github.com/", "")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Work order count pills - 3 custom pills with icons */}
|
||||
<div className="flex items-stretch gap-2 w-full">
|
||||
{/* Total pill */}
|
||||
<div className="relative flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 bg-pink-600 rounded-full blur-md",
|
||||
isSelected ? "opacity-30 dark:opacity-75" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center h-12 backdrop-blur-sm rounded-full border shadow-sm transition-all duration-300",
|
||||
isSelected
|
||||
? "bg-white/70 dark:bg-zinc-900/90 border-pink-300 dark:border-pink-500/50 dark:shadow-[0_0_10px_rgba(236,72,153,0.5)]"
|
||||
: "bg-white/30 dark:bg-zinc-900/30 border-gray-300/50 dark:border-gray-700/50",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center px-2 min-w-[40px]">
|
||||
<Clock
|
||||
className={cn(
|
||||
"w-4 h-4",
|
||||
isSelected ? "text-pink-600 dark:text-pink-400" : "text-gray-500 dark:text-gray-600",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[8px] font-medium",
|
||||
isSelected ? "text-pink-600 dark:text-pink-400" : "text-gray-500 dark:text-gray-600",
|
||||
)}
|
||||
>
|
||||
Total
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center border-l border-pink-300 dark:border-pink-500/30">
|
||||
<span
|
||||
className={cn(
|
||||
"text-lg font-bold",
|
||||
isSelected ? "text-pink-600 dark:text-pink-400" : "text-gray-500 dark:text-gray-600",
|
||||
)}
|
||||
>
|
||||
{stats.total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* In Progress pill */}
|
||||
<div className="relative flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 bg-blue-600 rounded-full blur-md",
|
||||
isSelected ? "opacity-30 dark:opacity-75" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center h-12 backdrop-blur-sm rounded-full border shadow-sm transition-all duration-300",
|
||||
isSelected
|
||||
? "bg-white/70 dark:bg-zinc-900/90 border-blue-300 dark:border-blue-500/50 dark:shadow-[0_0_10px_rgba(59,130,246,0.5)]"
|
||||
: "bg-white/30 dark:bg-zinc-900/30 border-gray-300/50 dark:border-gray-700/50",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center px-2 min-w-[40px]">
|
||||
<Activity
|
||||
className={cn(
|
||||
"w-4 h-4",
|
||||
isSelected ? "text-blue-600 dark:text-blue-400" : "text-gray-500 dark:text-gray-600",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[8px] font-medium",
|
||||
isSelected ? "text-blue-600 dark:text-blue-400" : "text-gray-500 dark:text-gray-600",
|
||||
)}
|
||||
>
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center border-l border-blue-300 dark:border-blue-500/30">
|
||||
<span
|
||||
className={cn(
|
||||
"text-lg font-bold",
|
||||
isSelected ? "text-blue-600 dark:text-blue-400" : "text-gray-500 dark:text-gray-600",
|
||||
)}
|
||||
>
|
||||
{stats.active}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Completed pill */}
|
||||
<div className="relative flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 bg-green-600 rounded-full blur-md",
|
||||
isSelected ? "opacity-30 dark:opacity-75" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center h-12 backdrop-blur-sm rounded-full border shadow-sm transition-all duration-300",
|
||||
isSelected
|
||||
? "bg-white/70 dark:bg-zinc-900/90 border-green-300 dark:border-green-500/50 dark:shadow-[0_0_10px_rgba(34,197,94,0.5)]"
|
||||
: "bg-white/30 dark:bg-zinc-900/30 border-gray-300/50 dark:border-gray-700/50",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center px-2 min-w-[40px]">
|
||||
<CheckCircle2
|
||||
className={cn(
|
||||
"w-4 h-4",
|
||||
isSelected ? "text-green-600 dark:text-green-400" : "text-gray-500 dark:text-gray-600",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[8px] font-medium",
|
||||
isSelected ? "text-green-600 dark:text-green-400" : "text-gray-500 dark:text-gray-600",
|
||||
)}
|
||||
>
|
||||
Done
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center border-l border-green-300 dark:border-green-500/30">
|
||||
<span
|
||||
className={cn(
|
||||
"text-lg font-bold",
|
||||
isSelected ? "text-green-600 dark:text-green-400" : "text-gray-500 dark:text-gray-600",
|
||||
)}
|
||||
>
|
||||
{stats.done}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verification status */}
|
||||
{repository.is_verified && (
|
||||
<div className="flex justify-center mt-3">
|
||||
<span className="text-xs text-green-600 dark:text-green-400">✓ Verified</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom bar with action icons */}
|
||||
<div className="flex items-center justify-end gap-2 px-3 py-2 mt-auto border-t border-gray-200/30 dark:border-gray-700/20">
|
||||
<TooltipProvider>
|
||||
{/* Edit button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEdit}
|
||||
className="p-1.5 rounded-md hover:bg-purple-500/10 dark:hover:bg-purple-500/20 text-gray-500 dark:text-gray-400 hover:text-purple-500 dark:hover:text-purple-400 transition-colors"
|
||||
aria-label="Edit repository"
|
||||
>
|
||||
<Edit className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Copy URL button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyUrl}
|
||||
className="p-1.5 rounded-md hover:bg-cyan-500/10 dark:hover:bg-cyan-500/20 text-gray-500 dark:text-gray-400 hover:text-cyan-500 dark:hover:text-cyan-400 transition-colors"
|
||||
aria-label="Copy repository URL"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy URL</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Delete button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
className="p-1.5 rounded-md hover:bg-red-500/10 dark:hover:bg-red-500/20 text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors"
|
||||
aria-label="Delete repository"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</SelectableCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Sidebar Repository Card Component
|
||||
*
|
||||
* Compact version of RepositoryCard for sidebar layout.
|
||||
* Shows repository name, pin badge, and inline stat pills.
|
||||
*/
|
||||
|
||||
import { Activity, CheckCircle2, Clock, Copy, Edit, Pin, Trash2 } from "lucide-react";
|
||||
import { StatPill } from "@/features/ui/primitives/pill";
|
||||
import { SelectableCard } from "@/features/ui/primitives/selectable-card";
|
||||
import { cn } from "@/features/ui/primitives/styles";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/features/ui/primitives/tooltip";
|
||||
import type { ConfiguredRepository } from "../types/repository";
|
||||
|
||||
export interface SidebarRepositoryCardProps {
|
||||
/** Repository data to display */
|
||||
repository: ConfiguredRepository;
|
||||
|
||||
/** Whether this repository is currently selected */
|
||||
isSelected?: boolean;
|
||||
|
||||
/** Whether this repository is pinned */
|
||||
isPinned?: boolean;
|
||||
|
||||
/** Whether to show aurora glow effect (when selected) */
|
||||
showAuroraGlow?: boolean;
|
||||
|
||||
/** Callback when repository is selected */
|
||||
onSelect?: () => void;
|
||||
|
||||
/** Callback when edit button is clicked */
|
||||
onEdit?: () => void;
|
||||
|
||||
/** Callback when delete button is clicked */
|
||||
onDelete?: () => void;
|
||||
|
||||
/** Work order statistics for this repository */
|
||||
stats?: {
|
||||
total: number;
|
||||
active: number;
|
||||
done: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to clipboard
|
||||
*/
|
||||
async function copyToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Static lookup map for background gradient classes
|
||||
*/
|
||||
const BACKGROUND_CLASSES = {
|
||||
pinned:
|
||||
"bg-gradient-to-b from-purple-100/80 via-purple-50/30 to-purple-100/50 dark:from-purple-900/30 dark:via-purple-900/20 dark:to-purple-900/10",
|
||||
selected:
|
||||
"bg-gradient-to-b from-white/70 via-purple-50/20 to-white/50 dark:from-white/5 dark:via-purple-900/5 dark:to-black/20",
|
||||
default: "bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Static lookup map for title text classes
|
||||
*/
|
||||
const TITLE_CLASSES = {
|
||||
selected: "text-purple-700 dark:text-purple-300",
|
||||
default: "text-gray-700 dark:text-gray-300",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get background class based on card state
|
||||
*/
|
||||
function getBackgroundClass(isPinned: boolean, isSelected: boolean): string {
|
||||
if (isPinned) return BACKGROUND_CLASSES.pinned;
|
||||
if (isSelected) return BACKGROUND_CLASSES.selected;
|
||||
return BACKGROUND_CLASSES.default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get title class based on card state
|
||||
*/
|
||||
function getTitleClass(isSelected: boolean): string {
|
||||
return isSelected ? TITLE_CLASSES.selected : TITLE_CLASSES.default;
|
||||
}
|
||||
|
||||
export function SidebarRepositoryCard({
|
||||
repository,
|
||||
isSelected = false,
|
||||
isPinned = false,
|
||||
showAuroraGlow = false,
|
||||
onSelect,
|
||||
onEdit,
|
||||
onDelete,
|
||||
stats = { total: 0, active: 0, done: 0 },
|
||||
}: SidebarRepositoryCardProps) {
|
||||
const backgroundClass = getBackgroundClass(isPinned, isSelected);
|
||||
const titleClass = getTitleClass(isSelected);
|
||||
|
||||
const handleCopyUrl = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const success = await copyToClipboard(repository.repository_url);
|
||||
if (success) {
|
||||
console.log("Repository URL copied to clipboard");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (onEdit) {
|
||||
onEdit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (onDelete) {
|
||||
onDelete();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectableCard
|
||||
isSelected={isSelected}
|
||||
isPinned={isPinned}
|
||||
showAuroraGlow={showAuroraGlow}
|
||||
onSelect={onSelect}
|
||||
size="none"
|
||||
blur="md"
|
||||
className={cn("p-2 w-56 flex flex-col", backgroundClass)}
|
||||
>
|
||||
{/* Main content */}
|
||||
<div className="space-y-2">
|
||||
{/* Title with pin badge - centered */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<h4 className={cn("font-medium text-sm line-clamp-1 text-center", titleClass)}>
|
||||
{repository.display_name || repository.repository_url}
|
||||
</h4>
|
||||
{isPinned && (
|
||||
<div
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 bg-purple-500 text-white text-[9px] font-bold rounded-full shrink-0"
|
||||
aria-label="Pinned repository"
|
||||
>
|
||||
<Pin className="w-2.5 h-2.5" fill="currentColor" aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Pills - all 3 in one row with icons - centered */}
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
<StatPill
|
||||
color="pink"
|
||||
value={stats.total}
|
||||
size="sm"
|
||||
icon={<Clock className="w-3 h-3" aria-hidden="true" />}
|
||||
aria-label={`${stats.total} total work orders`}
|
||||
/>
|
||||
<StatPill
|
||||
color="blue"
|
||||
value={stats.active}
|
||||
size="sm"
|
||||
icon={<Activity className="w-3 h-3" aria-hidden="true" />}
|
||||
aria-label={`${stats.active} active work orders`}
|
||||
/>
|
||||
<StatPill
|
||||
color="green"
|
||||
value={stats.done}
|
||||
size="sm"
|
||||
icon={<CheckCircle2 className="w-3 h-3" aria-hidden="true" />}
|
||||
aria-label={`${stats.done} completed work orders`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons bar */}
|
||||
<div className="flex items-center justify-center gap-2 px-2 py-2 mt-2 border-t border-gray-200/30 dark:border-gray-700/20">
|
||||
<TooltipProvider>
|
||||
{/* Edit button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEdit}
|
||||
className="p-1.5 rounded-md hover:bg-purple-500/10 dark:hover:bg-purple-500/20 text-gray-500 dark:text-gray-400 hover:text-purple-500 dark:hover:text-purple-400 transition-colors"
|
||||
aria-label="Edit repository"
|
||||
>
|
||||
<Edit className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Copy URL button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyUrl}
|
||||
className="p-1.5 rounded-md hover:bg-cyan-500/10 dark:hover:bg-cyan-500/20 text-gray-500 dark:text-gray-400 hover:text-cyan-500 dark:hover:text-cyan-400 transition-colors"
|
||||
aria-label="Copy repository URL"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy URL</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Delete button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
className="p-1.5 rounded-md hover:bg-red-500/10 dark:hover:bg-red-500/20 text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors"
|
||||
aria-label="Delete repository"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</SelectableCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AlertCircle, CheckCircle2, ChevronDown, ChevronUp, Edit3, Eye } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Button } from "@/features/ui/primitives/button";
|
||||
import { Card } from "@/features/ui/primitives/card";
|
||||
import { cn } from "@/features/ui/primitives/styles";
|
||||
|
||||
interface StepHistoryCardProps {
|
||||
step: {
|
||||
id: string;
|
||||
stepName: string;
|
||||
timestamp: string;
|
||||
output: string;
|
||||
session: string;
|
||||
collapsible: boolean;
|
||||
isHumanInLoop?: boolean;
|
||||
};
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
document?: {
|
||||
title: string;
|
||||
content: {
|
||||
markdown: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const StepHistoryCard = ({ step, isExpanded, onToggle, document }: StepHistoryCardProps) => {
|
||||
const [isEditingDocument, setIsEditingDocument] = useState(false);
|
||||
const [editedContent, setEditedContent] = useState("");
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const handleToggleEdit = () => {
|
||||
if (!isEditingDocument && document) {
|
||||
setEditedContent(document.content.markdown);
|
||||
}
|
||||
setIsEditingDocument(!isEditingDocument);
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const handleContentChange = (value: string) => {
|
||||
setEditedContent(value);
|
||||
setHasChanges(document ? value !== document.content.markdown : false);
|
||||
};
|
||||
|
||||
const handleApproveAndContinue = () => {
|
||||
console.log("Approved and continuing to next step");
|
||||
setHasChanges(false);
|
||||
setIsEditingDocument(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
blur="md"
|
||||
transparency="light"
|
||||
edgePosition="left"
|
||||
edgeColor={step.isHumanInLoop ? "orange" : "blue"}
|
||||
size="md"
|
||||
className="overflow-visible"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white">{step.stepName}</h4>
|
||||
{step.isHumanInLoop && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-md bg-orange-500/10 text-orange-600 dark:text-orange-400 border border-orange-500/20">
|
||||
<AlertCircle className="w-3 h-3" aria-hidden="true" />
|
||||
Human-in-Loop
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{step.timestamp}</p>
|
||||
</div>
|
||||
|
||||
{/* Collapse toggle - only show if collapsible */}
|
||||
{step.collapsible && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"px-2 transition-colors",
|
||||
step.isHumanInLoop
|
||||
? "text-orange-500 hover:text-orange-600 dark:hover:text-orange-400"
|
||||
: "text-cyan-500 hover:text-cyan-600 dark:hover:text-cyan-400",
|
||||
)}
|
||||
aria-label={isExpanded ? "Collapse step" : "Expand step"}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content - collapsible with animation */}
|
||||
<AnimatePresence mode="wait">
|
||||
{(isExpanded || !step.collapsible) && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{
|
||||
height: {
|
||||
duration: 0.3,
|
||||
ease: [0.04, 0.62, 0.23, 0.98],
|
||||
},
|
||||
opacity: {
|
||||
duration: 0.2,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
}}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: -20 }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: -20 }}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
{/* Output content */}
|
||||
<div
|
||||
className={cn(
|
||||
"p-4 rounded-lg border",
|
||||
step.isHumanInLoop
|
||||
? "bg-orange-50/50 dark:bg-orange-950/10 border-orange-200/50 dark:border-orange-800/30"
|
||||
: "bg-cyan-50/30 dark:bg-cyan-950/10 border-cyan-200/50 dark:border-cyan-800/30",
|
||||
)}
|
||||
>
|
||||
<pre className="text-xs font-mono text-gray-700 dark:text-gray-300 whitespace-pre-wrap leading-relaxed">
|
||||
{step.output}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Session info */}
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs font-mono",
|
||||
step.isHumanInLoop ? "text-orange-600 dark:text-orange-400" : "text-cyan-600 dark:text-cyan-400",
|
||||
)}
|
||||
>
|
||||
{step.session}
|
||||
</p>
|
||||
|
||||
{/* Review and Approve Plan - only for human-in-loop steps with documents */}
|
||||
{step.isHumanInLoop && document && (
|
||||
<div className="mt-6 space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">Review and Approve Plan</h4>
|
||||
|
||||
{/* Document Card */}
|
||||
<Card blur="md" transparency="light" size="md" className="overflow-visible">
|
||||
{/* View/Edit toggle in top right */}
|
||||
<div className="flex items-center justify-end mb-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleToggleEdit}
|
||||
className="text-gray-600 dark:text-gray-400 hover:bg-gray-500/10"
|
||||
aria-label={isEditingDocument ? "Switch to preview mode" : "Switch to edit mode"}
|
||||
>
|
||||
{isEditingDocument ? (
|
||||
<Eye className="w-4 h-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Edit3 className="w-4 h-4" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isEditingDocument ? (
|
||||
<div className="space-y-4">
|
||||
<textarea
|
||||
value={editedContent}
|
||||
onChange={(e) => handleContentChange(e.target.value)}
|
||||
className={cn(
|
||||
"w-full min-h-[300px] p-4 rounded-lg",
|
||||
"bg-white/50 dark:bg-black/30",
|
||||
"border border-gray-300 dark:border-gray-700",
|
||||
"text-gray-900 dark:text-white font-mono text-sm",
|
||||
"focus:outline-none focus:border-orange-400 focus:ring-2 focus:ring-orange-400/20",
|
||||
"resize-y",
|
||||
)}
|
||||
placeholder="Enter markdown content..."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
h1: ({ node, ...props }) => (
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-3 mt-4" {...props} />
|
||||
),
|
||||
h2: ({ node, ...props }) => (
|
||||
<h2
|
||||
className="text-lg font-semibold text-gray-900 dark:text-white mb-2 mt-3"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h3: ({ node, ...props }) => (
|
||||
<h3
|
||||
className="text-base font-semibold text-gray-900 dark:text-white mb-2 mt-3"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
p: ({ node, ...props }) => (
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 mb-2 leading-relaxed" {...props} />
|
||||
),
|
||||
ul: ({ node, ...props }) => (
|
||||
<ul
|
||||
className="list-disc list-inside text-sm text-gray-700 dark:text-gray-300 mb-2 space-y-1"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
li: ({ node, ...props }) => <li className="ml-4" {...props} />,
|
||||
code: ({ node, ...props }) => (
|
||||
<code
|
||||
className="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-xs font-mono text-orange-600 dark:text-orange-400"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{document.content.markdown}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Approve button - always visible with glass styling */}
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-gray-200/50 dark:border-gray-700/30">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{hasChanges ? "Unsaved changes" : "No changes"}
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleApproveAndContinue}
|
||||
className={cn(
|
||||
"backdrop-blur-md",
|
||||
"bg-gradient-to-b from-green-100/80 to-white/60",
|
||||
"dark:from-green-500/20 dark:to-green-500/10",
|
||||
"text-green-700 dark:text-green-100",
|
||||
"border border-green-300/50 dark:border-green-500/50",
|
||||
"hover:from-green-200/90 hover:to-green-100/70",
|
||||
"dark:hover:from-green-400/30 dark:hover:to-green-500/20",
|
||||
"hover:shadow-[0_0_20px_rgba(34,197,94,0.5)]",
|
||||
"dark:hover:shadow-[0_0_25px_rgba(34,197,94,0.7)]",
|
||||
"shadow-lg shadow-green-500/20",
|
||||
)}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" aria-hidden="true" />
|
||||
Approve and Move to Next Step
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* StepHistoryTimeline Component
|
||||
*
|
||||
* Displays a vertical timeline of step execution history with status,
|
||||
* duration, and error messages.
|
||||
*/
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import type { StepExecutionResult } from "../types";
|
||||
|
||||
interface StepHistoryTimelineProps {
|
||||
/** Array of executed steps */
|
||||
steps: StepExecutionResult[];
|
||||
/** Current phase being executed */
|
||||
currentPhase: string | null;
|
||||
}
|
||||
|
||||
const STEP_LABELS: Record<string, string> = {
|
||||
"create-branch": "Create Branch",
|
||||
planning: "Planning",
|
||||
execute: "Execute",
|
||||
commit: "Commit",
|
||||
"create-pr": "Create PR",
|
||||
"prp-review": "PRP Review",
|
||||
};
|
||||
|
||||
export function StepHistoryTimeline({ steps, currentPhase }: StepHistoryTimelineProps) {
|
||||
if (steps.length === 0) {
|
||||
return <div className="text-center py-8 text-gray-400">No steps executed yet</div>;
|
||||
}
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
if (seconds < 60) {
|
||||
return `${Math.round(seconds)}s`;
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.round(seconds % 60);
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{steps.map((step, index) => {
|
||||
const isLast = index === steps.length - 1;
|
||||
const isCurrent = currentPhase === step.step;
|
||||
const timeAgo = formatDistanceToNow(new Date(step.timestamp), {
|
||||
addSuffix: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div key={`${step.step}-${step.timestamp}`} className="flex gap-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center border-2 ${
|
||||
step.success ? "bg-green-500 border-green-400" : "bg-red-500 border-red-400"
|
||||
} ${isCurrent ? "animate-pulse" : ""}`}
|
||||
>
|
||||
{step.success ? (
|
||||
<span className="text-white text-sm">✓</span>
|
||||
) : (
|
||||
<span className="text-white text-sm">✗</span>
|
||||
)}
|
||||
</div>
|
||||
{!isLast && (
|
||||
<div className={`w-0.5 flex-1 min-h-[40px] ${step.success ? "bg-green-500" : "bg-red-500"}`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 pb-4">
|
||||
<div className="bg-gray-800 bg-opacity-50 backdrop-blur-sm border border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h4 className="text-white font-semibold">{STEP_LABELS[step.step] || step.step}</h4>
|
||||
<p className="text-sm text-gray-400 mt-1">{step.agent_name}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div
|
||||
className={`text-xs font-medium px-2 py-1 rounded ${
|
||||
step.success
|
||||
? "bg-green-900 bg-opacity-30 text-green-400"
|
||||
: "bg-red-900 bg-opacity-30 text-red-400"
|
||||
}`}
|
||||
>
|
||||
{formatDuration(step.duration_seconds)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">{timeAgo}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{step.output && (
|
||||
<div className="mt-3 p-3 bg-gray-900 bg-opacity-50 rounded border border-gray-700">
|
||||
<p className="text-sm text-gray-300 font-mono whitespace-pre-wrap">
|
||||
{step.output.length > 500 ? `${step.output.substring(0, 500)}...` : step.output}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.error_message && (
|
||||
<div className="mt-3 p-3 bg-red-900 bg-opacity-30 border border-red-700 rounded">
|
||||
<p className="text-sm text-red-300 font-mono whitespace-pre-wrap">{step.error_message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.session_id && <div className="mt-2 text-xs text-gray-500">Session: {step.session_id}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
/**
|
||||
* WorkOrderCard Component
|
||||
*
|
||||
* Displays a summary card for a single work order with status badge,
|
||||
* repository info, and key metadata.
|
||||
*/
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import type { AgentWorkOrder } from "../types";
|
||||
|
||||
interface WorkOrderCardProps {
|
||||
/** Work order to display */
|
||||
workOrder: AgentWorkOrder;
|
||||
/** Callback when card is clicked */
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<AgentWorkOrder["status"], { bg: string; text: string; label: string }> = {
|
||||
pending: {
|
||||
bg: "bg-gray-700",
|
||||
text: "text-gray-300",
|
||||
label: "Pending",
|
||||
},
|
||||
running: {
|
||||
bg: "bg-blue-600",
|
||||
text: "text-blue-100",
|
||||
label: "Running",
|
||||
},
|
||||
completed: {
|
||||
bg: "bg-green-600",
|
||||
text: "text-green-100",
|
||||
label: "Completed",
|
||||
},
|
||||
failed: {
|
||||
bg: "bg-red-600",
|
||||
text: "text-red-100",
|
||||
label: "Failed",
|
||||
},
|
||||
};
|
||||
|
||||
export function WorkOrderCard({ workOrder, onClick }: WorkOrderCardProps) {
|
||||
const statusStyle = STATUS_STYLES[workOrder.status];
|
||||
const repoName = workOrder.repository_url.split("/").slice(-2).join("/");
|
||||
const timeAgo = formatDistanceToNow(new Date(workOrder.created_at), {
|
||||
addSuffix: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onClick?.();
|
||||
}
|
||||
}}
|
||||
role={onClick ? "button" : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
className="bg-gray-800 bg-opacity-50 backdrop-blur-sm border border-gray-700 rounded-lg p-4 hover:border-blue-500 transition-all cursor-pointer"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-white truncate">{repoName}</h3>
|
||||
<p className="text-sm text-gray-400 mt-1">{timeAgo}</p>
|
||||
</div>
|
||||
<div className={`px-3 py-1 rounded-full text-xs font-medium ${statusStyle.bg} ${statusStyle.text} ml-3`}>
|
||||
{statusStyle.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{workOrder.current_phase && (
|
||||
<div className="mb-2">
|
||||
<p className="text-sm text-gray-300">
|
||||
Phase: <span className="text-blue-400">{workOrder.current_phase}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workOrder.git_branch_name && (
|
||||
<div className="mb-2">
|
||||
<p className="text-sm text-gray-300">
|
||||
Branch: <span className="text-cyan-400 font-mono text-xs">{workOrder.git_branch_name}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workOrder.github_pull_request_url && (
|
||||
<div className="mb-2">
|
||||
<a
|
||||
href={workOrder.github_pull_request_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-400 hover:text-blue-300 underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
View Pull Request
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workOrder.error_message && (
|
||||
<div className="mt-2 p-2 bg-red-900 bg-opacity-30 border border-red-700 rounded text-xs text-red-300">
|
||||
{workOrder.error_message.length > 100
|
||||
? `${workOrder.error_message.substring(0, 100)}...`
|
||||
: workOrder.error_message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 mt-3 text-xs text-gray-500">
|
||||
{workOrder.git_commit_count > 0 && <span>{workOrder.git_commit_count} commits</span>}
|
||||
{workOrder.git_files_changed > 0 && <span>{workOrder.git_files_changed} files changed</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* WorkOrderList Component
|
||||
*
|
||||
* Displays a filterable list of agent work orders with status filters and search.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useWorkOrders } from "../hooks/useAgentWorkOrderQueries";
|
||||
import type { AgentWorkOrderStatus } from "../types";
|
||||
import { WorkOrderCard } from "./WorkOrderCard";
|
||||
|
||||
interface WorkOrderListProps {
|
||||
/** Callback when a work order card is clicked */
|
||||
onWorkOrderClick?: (workOrderId: string) => void;
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS: Array<{
|
||||
value: AgentWorkOrderStatus | "all";
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "pending", label: "Pending" },
|
||||
{ value: "running", label: "Running" },
|
||||
{ value: "completed", label: "Completed" },
|
||||
{ value: "failed", label: "Failed" },
|
||||
];
|
||||
|
||||
export function WorkOrderList({ onWorkOrderClick }: WorkOrderListProps) {
|
||||
const [statusFilter, setStatusFilter] = useState<AgentWorkOrderStatus | "all">("all");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const queryFilter = statusFilter === "all" ? undefined : statusFilter;
|
||||
const { data: workOrders, isLoading, isError } = useWorkOrders(queryFilter);
|
||||
|
||||
const filteredWorkOrders = useMemo(() => {
|
||||
if (!workOrders) return [];
|
||||
|
||||
return workOrders.filter((wo) => {
|
||||
const matchesSearch =
|
||||
searchQuery === "" ||
|
||||
wo.repository_url.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
wo.agent_work_order_id.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
return matchesSearch;
|
||||
});
|
||||
}, [workOrders, searchQuery]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div
|
||||
key={`skeleton-${
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: skeleton loading
|
||||
i
|
||||
}`}
|
||||
className="h-40 bg-gray-800 bg-opacity-50 rounded-lg animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-400">Failed to load work orders</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search by repository or ID..."
|
||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as AgentWorkOrderStatus | "all")}
|
||||
className="w-full sm:w-auto px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredWorkOrders.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-400">{searchQuery ? "No work orders match your search" : "No work orders found"}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredWorkOrders.map((workOrder) => (
|
||||
<WorkOrderCard
|
||||
key={workOrder.agent_work_order_id}
|
||||
workOrder={workOrder}
|
||||
onClick={() => onWorkOrderClick?.(workOrder.agent_work_order_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
/**
|
||||
* WorkOrderLogsPanel Component
|
||||
*
|
||||
* Terminal-style log viewer for real-time work order execution logs.
|
||||
* Connects to SSE endpoint and displays logs with filtering and auto-scroll capabilities.
|
||||
*/
|
||||
|
||||
import { ChevronDown, ChevronUp, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/features/ui/primitives/button";
|
||||
import { useWorkOrderLogs } from "../hooks/useWorkOrderLogs";
|
||||
import type { LogEntry } from "../types";
|
||||
|
||||
interface WorkOrderLogsPanelProps {
|
||||
/** Work order ID to stream logs for */
|
||||
workOrderId: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color class for log level badge
|
||||
*/
|
||||
function getLogLevelColor(level: string): string {
|
||||
switch (level) {
|
||||
case "info":
|
||||
return "bg-blue-500/20 text-blue-400 border-blue-400/30";
|
||||
case "warning":
|
||||
return "bg-yellow-500/20 text-yellow-400 border-yellow-400/30";
|
||||
case "error":
|
||||
return "bg-red-500/20 text-red-400 border-red-400/30";
|
||||
case "debug":
|
||||
return "bg-gray-500/20 text-gray-400 border-gray-400/30";
|
||||
default:
|
||||
return "bg-gray-500/20 text-gray-400 border-gray-400/30";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp to relative time
|
||||
*/
|
||||
function formatRelativeTime(timestamp: string): string {
|
||||
const now = Date.now();
|
||||
const logTime = new Date(timestamp).getTime();
|
||||
const diffSeconds = Math.floor((now - logTime) / 1000);
|
||||
|
||||
if (diffSeconds < 60) return `${diffSeconds}s ago`;
|
||||
if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)}m ago`;
|
||||
if (diffSeconds < 86400) return `${Math.floor(diffSeconds / 3600)}h ago`;
|
||||
return `${Math.floor(diffSeconds / 86400)}d ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual log entry component
|
||||
*/
|
||||
function LogEntryRow({ log }: { log: LogEntry }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 py-1 px-2 hover:bg-white/5 rounded font-mono text-sm">
|
||||
<span className="text-gray-500 text-xs whitespace-nowrap">{formatRelativeTime(log.timestamp)}</span>
|
||||
<span
|
||||
className={`px-1.5 py-0.5 rounded text-xs border uppercase whitespace-nowrap ${getLogLevelColor(log.level)}`}
|
||||
>
|
||||
{log.level}
|
||||
</span>
|
||||
{log.step && <span className="text-cyan-400 text-xs whitespace-nowrap">[{log.step}]</span>}
|
||||
<span className="text-gray-300 flex-1">{log.event}</span>
|
||||
{log.progress && <span className="text-gray-500 text-xs whitespace-nowrap">{log.progress}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkOrderLogsPanel({ workOrderId }: WorkOrderLogsPanelProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [levelFilter, setLevelFilter] = useState<"info" | "warning" | "error" | "debug" | undefined>(undefined);
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { logs, connectionState, isConnected, error, reconnect, clearLogs } = useWorkOrderLogs({
|
||||
workOrderId,
|
||||
levelFilter,
|
||||
autoReconnect: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Auto-scroll to bottom when new logs arrive
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (autoScroll && scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [autoScroll]);
|
||||
|
||||
/**
|
||||
* Detect manual scroll and disable auto-scroll
|
||||
*/
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
|
||||
|
||||
if (!isAtBottom && autoScroll) {
|
||||
setAutoScroll(false);
|
||||
} else if (isAtBottom && !autoScroll) {
|
||||
setAutoScroll(true);
|
||||
}
|
||||
}, [autoScroll]);
|
||||
|
||||
/**
|
||||
* Filter logs by level if filter is active
|
||||
*/
|
||||
const filteredLogs = levelFilter ? logs.filter((log) => log.level === levelFilter) : logs;
|
||||
|
||||
return (
|
||||
<div className="border border-white/10 rounded-lg overflow-hidden bg-black/20 backdrop-blur">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center gap-2 text-gray-300 hover:text-white transition-colors"
|
||||
>
|
||||
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
<span className="font-semibold">Execution Logs</span>
|
||||
</button>
|
||||
|
||||
{/* Connection status indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
{connectionState === "connecting" && <span className="text-xs text-gray-500">Connecting...</span>}
|
||||
{isConnected && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
<span className="text-xs text-green-400">Live</span>
|
||||
</div>
|
||||
)}
|
||||
{connectionState === "error" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full" />
|
||||
<span className="text-xs text-red-400">Disconnected</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-gray-500">({filteredLogs.length} entries)</span>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Level filter */}
|
||||
<select
|
||||
value={levelFilter || ""}
|
||||
onChange={(e) => setLevelFilter((e.target.value as "info" | "warning" | "error" | "debug") || undefined)}
|
||||
className="bg-white/5 border border-white/10 rounded px-2 py-1 text-xs text-gray-300 hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<option value="">All Levels</option>
|
||||
<option value="info">Info</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="error">Error</option>
|
||||
<option value="debug">Debug</option>
|
||||
</select>
|
||||
|
||||
{/* Auto-scroll toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setAutoScroll(!autoScroll)}
|
||||
className={autoScroll ? "text-cyan-400" : "text-gray-500"}
|
||||
title={autoScroll ? "Auto-scroll enabled" : "Auto-scroll disabled"}
|
||||
>
|
||||
Auto-scroll: {autoScroll ? "ON" : "OFF"}
|
||||
</Button>
|
||||
|
||||
{/* Clear logs */}
|
||||
<Button variant="ghost" size="sm" onClick={clearLogs} title="Clear logs">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* Reconnect button */}
|
||||
{connectionState === "error" && (
|
||||
<Button variant="ghost" size="sm" onClick={reconnect} title="Reconnect">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log content */}
|
||||
{isExpanded && (
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="max-h-96 overflow-y-auto bg-black/40"
|
||||
style={{ scrollBehavior: autoScroll ? "smooth" : "auto" }}
|
||||
>
|
||||
{/* Empty state */}
|
||||
{filteredLogs.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
|
||||
{connectionState === "connecting" && <p>Connecting to log stream...</p>}
|
||||
{connectionState === "error" && (
|
||||
<div className="text-center">
|
||||
<p className="text-red-400">Failed to connect to log stream</p>
|
||||
{error && <p className="text-xs text-gray-500 mt-1">{error.message}</p>}
|
||||
<Button onClick={reconnect} className="mt-4">
|
||||
Retry Connection
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isConnected && logs.length === 0 && <p>No logs yet. Waiting for execution...</p>}
|
||||
{isConnected && logs.length > 0 && filteredLogs.length === 0 && <p>No logs match the current filter</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log entries */}
|
||||
{filteredLogs.length > 0 && (
|
||||
<div className="p-2">
|
||||
{filteredLogs.map((log, index) => (
|
||||
<LogEntryRow key={`${log.timestamp}-${index}`} log={log} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* WorkOrderProgressBar Component
|
||||
*
|
||||
* Displays visual progress of a work order through its workflow steps.
|
||||
* Shows 5 steps with visual indicators for pending, running, success, and failed states.
|
||||
*/
|
||||
|
||||
import type { StepExecutionResult, WorkflowStep } from "../types";
|
||||
|
||||
interface WorkOrderProgressBarProps {
|
||||
/** Array of executed steps */
|
||||
steps: StepExecutionResult[];
|
||||
/** Current phase/step being executed */
|
||||
currentPhase: string | null;
|
||||
}
|
||||
|
||||
const WORKFLOW_STEPS: WorkflowStep[] = ["create-branch", "planning", "execute", "commit", "create-pr"];
|
||||
|
||||
const STEP_LABELS: Record<WorkflowStep, string> = {
|
||||
"create-branch": "Create Branch",
|
||||
planning: "Planning",
|
||||
execute: "Execute",
|
||||
commit: "Commit",
|
||||
"create-pr": "Create PR",
|
||||
"prp-review": "PRP Review",
|
||||
};
|
||||
|
||||
export function WorkOrderProgressBar({ steps, currentPhase }: WorkOrderProgressBarProps) {
|
||||
const getStepStatus = (stepName: WorkflowStep): "pending" | "running" | "success" | "failed" => {
|
||||
const stepResult = steps.find((s) => s.step === stepName);
|
||||
|
||||
if (!stepResult) {
|
||||
return currentPhase === stepName ? "running" : "pending";
|
||||
}
|
||||
|
||||
return stepResult.success ? "success" : "failed";
|
||||
};
|
||||
|
||||
const getStepStyles = (status: string): string => {
|
||||
switch (status) {
|
||||
case "success":
|
||||
return "bg-green-500 border-green-400 text-white";
|
||||
case "failed":
|
||||
return "bg-red-500 border-red-400 text-white";
|
||||
case "running":
|
||||
return "bg-blue-500 border-blue-400 text-white animate-pulse";
|
||||
default:
|
||||
return "bg-gray-700 border-gray-600 text-gray-400";
|
||||
}
|
||||
};
|
||||
|
||||
const getConnectorStyles = (status: string): string => {
|
||||
switch (status) {
|
||||
case "success":
|
||||
return "bg-green-500";
|
||||
case "failed":
|
||||
return "bg-red-500";
|
||||
case "running":
|
||||
return "bg-blue-500";
|
||||
default:
|
||||
return "bg-gray-700";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{WORKFLOW_STEPS.map((step, index) => {
|
||||
const status = getStepStatus(step);
|
||||
const isLast = index === WORKFLOW_STEPS.length - 1;
|
||||
|
||||
return (
|
||||
<div key={step} className="flex items-center flex-1">
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full border-2 flex items-center justify-center font-semibold transition-all ${getStepStyles(status)}`}
|
||||
>
|
||||
{status === "success" ? (
|
||||
<span>✓</span>
|
||||
) : status === "failed" ? (
|
||||
<span>✗</span>
|
||||
) : status === "running" ? (
|
||||
<span className="text-sm">•••</span>
|
||||
) : (
|
||||
<span className="text-xs">{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-center text-gray-300 max-w-[80px]">{STEP_LABELS[step]}</div>
|
||||
</div>
|
||||
{!isLast && <div className={`flex-1 h-1 mx-2 transition-all ${getConnectorStyles(status)}`} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Work Order Row Component
|
||||
*
|
||||
* Individual table row for a work order with status indicator, start/details buttons,
|
||||
* and expandable real-time stats section.
|
||||
*/
|
||||
|
||||
import { ChevronDown, ChevronUp, Eye, Play } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/features/ui/primitives/button";
|
||||
import { type PillColor, StatPill } from "@/features/ui/primitives/pill";
|
||||
import { cn } from "@/features/ui/primitives/styles";
|
||||
import { RealTimeStats } from "./RealTimeStats";
|
||||
import type { AgentWorkOrder } from "../types";
|
||||
|
||||
export interface WorkOrderRowProps {
|
||||
/** Work order data */
|
||||
workOrder: AgentWorkOrder;
|
||||
|
||||
/** Repository display name (from configured repository) */
|
||||
repositoryDisplayName?: string;
|
||||
|
||||
/** Row index for alternating backgrounds */
|
||||
index: number;
|
||||
|
||||
/** Callback when start button is clicked */
|
||||
onStart: (id: string) => void;
|
||||
|
||||
/** Whether this row was just started (auto-expand) */
|
||||
wasJustStarted?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status color configuration
|
||||
* Static lookup to avoid dynamic class construction
|
||||
*/
|
||||
interface StatusConfig {
|
||||
color: PillColor;
|
||||
edge: string;
|
||||
glow: string;
|
||||
label: string;
|
||||
stepNumber: number;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, StatusConfig> = {
|
||||
pending: {
|
||||
color: "pink",
|
||||
edge: "bg-pink-500",
|
||||
glow: "rgba(236,72,153,0.5)",
|
||||
label: "Pending",
|
||||
stepNumber: 0,
|
||||
},
|
||||
running: {
|
||||
color: "cyan",
|
||||
edge: "bg-cyan-500",
|
||||
glow: "rgba(34,211,238,0.5)",
|
||||
label: "Running",
|
||||
stepNumber: 1,
|
||||
},
|
||||
completed: {
|
||||
color: "green",
|
||||
edge: "bg-green-500",
|
||||
glow: "rgba(34,197,94,0.5)",
|
||||
label: "Completed",
|
||||
stepNumber: 5,
|
||||
},
|
||||
failed: {
|
||||
color: "orange",
|
||||
edge: "bg-orange-500",
|
||||
glow: "rgba(249,115,22,0.5)",
|
||||
label: "Failed",
|
||||
stepNumber: 0,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get status configuration with fallback
|
||||
*/
|
||||
function getStatusConfig(status: string): StatusConfig {
|
||||
return STATUS_COLORS[status] || STATUS_COLORS.pending;
|
||||
}
|
||||
|
||||
export function WorkOrderRow({
|
||||
workOrder,
|
||||
repositoryDisplayName,
|
||||
index,
|
||||
onStart,
|
||||
wasJustStarted = false,
|
||||
}: WorkOrderRowProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(wasJustStarted);
|
||||
const navigate = useNavigate();
|
||||
const statusConfig = getStatusConfig(workOrder.status);
|
||||
|
||||
const handleStartClick = () => {
|
||||
setIsExpanded(true); // Auto-expand when started
|
||||
onStart(workOrder.agent_work_order_id);
|
||||
};
|
||||
|
||||
const handleDetailsClick = () => {
|
||||
navigate(`/agent-work-orders/${workOrder.agent_work_order_id}`);
|
||||
};
|
||||
|
||||
const isPending = workOrder.status === "pending";
|
||||
const canExpand = !isPending; // Only non-pending rows can be expanded
|
||||
|
||||
// Use display name if available, otherwise extract from URL
|
||||
const displayRepo = repositoryDisplayName || workOrder.repository_url.split("/").slice(-2).join("/");
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Main row */}
|
||||
<tr
|
||||
className={cn(
|
||||
"group transition-all duration-200",
|
||||
index % 2 === 0 ? "bg-white/50 dark:bg-black/50" : "bg-gray-50/80 dark:bg-gray-900/30",
|
||||
"hover:bg-gradient-to-r hover:from-cyan-50/70 hover:to-purple-50/70 dark:hover:from-cyan-900/20 dark:hover:to-purple-900/20",
|
||||
"border-b border-gray-200 dark:border-gray-800",
|
||||
)}
|
||||
>
|
||||
{/* Status indicator - glowing circle with optional collapse button */}
|
||||
<td className="px-3 py-2 w-12">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{canExpand && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
aria-label={isExpanded ? "Collapse details" : "Expand details"}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-3 h-3 text-gray-600 dark:text-gray-400" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3 text-gray-600 dark:text-gray-400" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<div className={cn("w-3 h-3 rounded-full", statusConfig.edge)} style={{ boxShadow: `0 0 8px ${statusConfig.glow}` }} />
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Work Order ID */}
|
||||
<td className="px-4 py-2">
|
||||
<span className="font-mono text-sm text-gray-700 dark:text-gray-300">{workOrder.agent_work_order_id}</span>
|
||||
</td>
|
||||
|
||||
{/* Repository */}
|
||||
<td className="px-4 py-2 w-40">
|
||||
<span className="text-sm text-gray-900 dark:text-white">{displayRepo}</span>
|
||||
</td>
|
||||
|
||||
{/* Request Summary */}
|
||||
<td className="px-4 py-2">
|
||||
<p className="text-sm text-gray-900 dark:text-white line-clamp-2">
|
||||
{workOrder.github_issue_number ? `Issue #${workOrder.github_issue_number}` : "Work order in progress"}
|
||||
</p>
|
||||
</td>
|
||||
|
||||
{/* Status Badge - using StatPill */}
|
||||
<td className="px-4 py-2 w-32">
|
||||
<StatPill color={statusConfig.color} value={statusConfig.label} size="sm" />
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-4 py-2 w-32">
|
||||
{isPending ? (
|
||||
<Button
|
||||
onClick={handleStartClick}
|
||||
size="xs"
|
||||
variant="green"
|
||||
className="w-full text-xs"
|
||||
aria-label="Start work order"
|
||||
>
|
||||
<Play className="w-3 h-3 mr-1" aria-hidden="true" />
|
||||
Start
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleDetailsClick}
|
||||
size="xs"
|
||||
variant="blue"
|
||||
className="w-full text-xs"
|
||||
aria-label="View work order details"
|
||||
>
|
||||
<Eye className="w-3 h-3 mr-1" aria-hidden="true" />
|
||||
Details
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Expanded row with real-time stats */}
|
||||
{isExpanded && canExpand && (
|
||||
<tr
|
||||
className={cn(
|
||||
index % 2 === 0 ? "bg-white/50 dark:bg-black/50" : "bg-gray-50/80 dark:bg-gray-900/30",
|
||||
"border-b border-gray-200 dark:border-gray-800",
|
||||
)}
|
||||
>
|
||||
<td colSpan={6} className="px-4 py-4">
|
||||
<RealTimeStats workOrderId={workOrder.agent_work_order_id} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Work Order Table Component
|
||||
*
|
||||
* Displays work orders in a table with start buttons, status indicators,
|
||||
* and expandable real-time stats.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRepositories } from "../hooks/useRepositoryQueries";
|
||||
import type { AgentWorkOrder } from "../types";
|
||||
import { WorkOrderRow } from "./WorkOrderRow";
|
||||
|
||||
export interface WorkOrderTableProps {
|
||||
/** Array of work orders to display */
|
||||
workOrders: AgentWorkOrder[];
|
||||
|
||||
/** Optional repository ID to filter work orders */
|
||||
selectedRepositoryId?: string;
|
||||
|
||||
/** Callback when start button is clicked */
|
||||
onStartWorkOrder: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced work order with repository display name
|
||||
*/
|
||||
interface EnhancedWorkOrder extends AgentWorkOrder {
|
||||
repositoryDisplayName?: string;
|
||||
}
|
||||
|
||||
export function WorkOrderTable({ workOrders, selectedRepositoryId, onStartWorkOrder }: WorkOrderTableProps) {
|
||||
const [justStartedId, setJustStartedId] = useState<string | null>(null);
|
||||
const { data: repositories = [] } = useRepositories();
|
||||
|
||||
// Create a map of repository URL to display name for quick lookup
|
||||
const repoUrlToDisplayName = repositories.reduce(
|
||||
(acc, repo) => {
|
||||
acc[repo.repository_url] = repo.display_name || repo.repository_url.split("/").slice(-2).join("/");
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
|
||||
// Filter work orders based on selected repository
|
||||
// Find the repository URL from the selected repository ID, then filter work orders by that URL
|
||||
const filteredWorkOrders = selectedRepositoryId
|
||||
? (() => {
|
||||
const selectedRepo = repositories.find((r) => r.id === selectedRepositoryId);
|
||||
return selectedRepo
|
||||
? workOrders.filter((wo) => wo.repository_url === selectedRepo.repository_url)
|
||||
: workOrders;
|
||||
})()
|
||||
: workOrders;
|
||||
|
||||
// Enhance work orders with display names
|
||||
const enhancedWorkOrders: EnhancedWorkOrder[] = filteredWorkOrders.map((wo) => ({
|
||||
...wo,
|
||||
repositoryDisplayName: repoUrlToDisplayName[wo.repository_url],
|
||||
}));
|
||||
|
||||
/**
|
||||
* Handle start button click with auto-expand tracking
|
||||
*/
|
||||
const handleStart = (id: string) => {
|
||||
setJustStartedId(id);
|
||||
onStartWorkOrder(id);
|
||||
|
||||
// Clear the tracking after animation
|
||||
setTimeout(() => setJustStartedId(null), 1000);
|
||||
};
|
||||
|
||||
// Show empty state if no work orders
|
||||
if (filteredWorkOrders.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-2">No work orders found</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
{selectedRepositoryId
|
||||
? "Create a work order for this repository to get started"
|
||||
: "Create a work order to get started"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-x-auto scrollbar-hide">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 border-b-2 border-gray-200 dark:border-gray-700">
|
||||
<th className="w-12" aria-label="Status indicator" />
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">WO ID</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300 w-40">
|
||||
Repository
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Request Summary
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300 w-32">Status</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300 w-32">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{enhancedWorkOrders.map((workOrder, index) => (
|
||||
<WorkOrderRow
|
||||
key={workOrder.agent_work_order_id}
|
||||
workOrder={workOrder}
|
||||
repositoryDisplayName={workOrder.repositoryDisplayName}
|
||||
index={index}
|
||||
onStart={handleStart}
|
||||
wasJustStarted={workOrder.agent_work_order_id === justStartedId}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { motion } from "framer-motion";
|
||||
import type React from "react";
|
||||
import { cn } from "@/features/ui/primitives/styles";
|
||||
|
||||
interface WorkflowStepButtonProps {
|
||||
isCompleted: boolean;
|
||||
isActive: boolean;
|
||||
stepName: string;
|
||||
onClick?: () => void;
|
||||
color?: "cyan" | "green" | "blue" | "purple";
|
||||
size?: number;
|
||||
}
|
||||
|
||||
// Helper function to get color hex values for animations
|
||||
const getColorValue = (color: string) => {
|
||||
const colorValues = {
|
||||
purple: "rgb(168,85,247)",
|
||||
green: "rgb(34,197,94)",
|
||||
blue: "rgb(59,130,246)",
|
||||
cyan: "rgb(34,211,238)",
|
||||
};
|
||||
return colorValues[color as keyof typeof colorValues] || colorValues.blue;
|
||||
};
|
||||
|
||||
export const WorkflowStepButton: React.FC<WorkflowStepButtonProps> = ({
|
||||
isCompleted,
|
||||
isActive,
|
||||
stepName,
|
||||
onClick,
|
||||
color = "cyan",
|
||||
size = 40,
|
||||
}) => {
|
||||
const colorMap = {
|
||||
purple: {
|
||||
border: "border-purple-400 dark:border-purple-300",
|
||||
glow: "shadow-[0_0_15px_rgba(168,85,247,0.8)]",
|
||||
glowHover: "hover:shadow-[0_0_25px_rgba(168,85,247,1)]",
|
||||
fill: "bg-purple-400 dark:bg-purple-300",
|
||||
innerGlow: "shadow-[inset_0_0_10px_rgba(168,85,247,0.8)]",
|
||||
},
|
||||
green: {
|
||||
border: "border-green-400 dark:border-green-300",
|
||||
glow: "shadow-[0_0_15px_rgba(34,197,94,0.8)]",
|
||||
glowHover: "hover:shadow-[0_0_25px_rgba(34,197,94,1)]",
|
||||
fill: "bg-green-400 dark:bg-green-300",
|
||||
innerGlow: "shadow-[inset_0_0_10px_rgba(34,197,94,0.8)]",
|
||||
},
|
||||
blue: {
|
||||
border: "border-blue-400 dark:border-blue-300",
|
||||
glow: "shadow-[0_0_15px_rgba(59,130,246,0.8)]",
|
||||
glowHover: "hover:shadow-[0_0_25px_rgba(59,130,246,1)]",
|
||||
fill: "bg-blue-400 dark:bg-blue-300",
|
||||
innerGlow: "shadow-[inset_0_0_10px_rgba(59,130,246,0.8)]",
|
||||
},
|
||||
cyan: {
|
||||
border: "border-cyan-400 dark:border-cyan-300",
|
||||
glow: "shadow-[0_0_15px_rgba(34,211,238,0.8)]",
|
||||
glowHover: "hover:shadow-[0_0_25px_rgba(34,211,238,1)]",
|
||||
fill: "bg-cyan-400 dark:bg-cyan-300",
|
||||
innerGlow: "shadow-[inset_0_0_10px_rgba(34,211,238,0.8)]",
|
||||
},
|
||||
};
|
||||
|
||||
const styles = colorMap[color];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<motion.button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"relative rounded-full border-2 transition-all duration-300",
|
||||
styles.border,
|
||||
isCompleted ? styles.glow : "shadow-[0_0_5px_rgba(0,0,0,0.3)]",
|
||||
styles.glowHover,
|
||||
"bg-gradient-to-b from-gray-900 to-black dark:from-gray-800 dark:to-gray-900",
|
||||
"hover:scale-110 active:scale-95",
|
||||
)}
|
||||
style={{ width: size, height: size }}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
aria-label={`${stepName} - ${isCompleted ? "completed" : isActive ? "in progress" : "pending"}`}
|
||||
>
|
||||
{/* Outer ring glow effect */}
|
||||
<motion.div
|
||||
className={cn(
|
||||
"absolute inset-[-4px] rounded-full border-2 blur-sm",
|
||||
isCompleted ? styles.border : "border-transparent",
|
||||
)}
|
||||
animate={{
|
||||
opacity: isCompleted ? [0.3, 0.6, 0.3] : 0,
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Inner glow effect */}
|
||||
<motion.div
|
||||
className={cn("absolute inset-[2px] rounded-full blur-md opacity-20", isCompleted && styles.fill)}
|
||||
animate={{
|
||||
opacity: isCompleted ? [0.1, 0.3, 0.1] : 0,
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Checkmark icon container */}
|
||||
<div className="relative w-full h-full flex items-center justify-center">
|
||||
<motion.svg
|
||||
width={size * 0.5}
|
||||
height={size * 0.5}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className="relative z-10"
|
||||
role="img"
|
||||
aria-label={`${stepName} status indicator`}
|
||||
animate={{
|
||||
filter: isCompleted
|
||||
? [
|
||||
`drop-shadow(0 0 8px ${getColorValue(color)}) drop-shadow(0 0 12px ${getColorValue(color)})`,
|
||||
`drop-shadow(0 0 12px ${getColorValue(color)}) drop-shadow(0 0 16px ${getColorValue(color)})`,
|
||||
`drop-shadow(0 0 8px ${getColorValue(color)}) drop-shadow(0 0 12px ${getColorValue(color)})`,
|
||||
]
|
||||
: "none",
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
{/* Checkmark path */}
|
||||
<path
|
||||
d="M20 6L9 17l-5-5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={isCompleted ? "text-white" : "text-gray-600"}
|
||||
/>
|
||||
</motion.svg>
|
||||
</div>
|
||||
</motion.button>
|
||||
|
||||
{/* Step name label */}
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium transition-colors",
|
||||
isCompleted
|
||||
? "text-cyan-400 dark:text-cyan-300"
|
||||
: isActive
|
||||
? "text-blue-500 dark:text-blue-400"
|
||||
: "text-gray-500 dark:text-gray-400",
|
||||
)}
|
||||
>
|
||||
{stepName}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* CreateWorkOrderModal Component Tests
|
||||
*
|
||||
* Tests for create work order modal form validation and submission.
|
||||
*/
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CreateWorkOrderModal } from "../CreateWorkOrderModal";
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock("../../hooks/useAgentWorkOrderQueries", () => ({
|
||||
useCreateWorkOrder: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({
|
||||
agent_work_order_id: "wo-new",
|
||||
status: "pending",
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useRepositoryQueries", () => ({
|
||||
useRepositories: () => ({
|
||||
data: [
|
||||
{
|
||||
id: "repo-1",
|
||||
repository_url: "https://github.com/test/repo",
|
||||
display_name: "test/repo",
|
||||
default_sandbox_type: "git_worktree",
|
||||
default_commands: ["create-branch", "planning", "execute"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/ui/hooks/useToast", () => ({
|
||||
useToast: () => ({
|
||||
showToast: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("CreateWorkOrderModal", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
it("should render when open", () => {
|
||||
render(<CreateWorkOrderModal open={true} onOpenChange={vi.fn()} />, { wrapper });
|
||||
|
||||
expect(screen.getByText("Create Work Order")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render when closed", () => {
|
||||
render(<CreateWorkOrderModal open={false} onOpenChange={vi.fn()} />, { wrapper });
|
||||
|
||||
expect(screen.queryByText("Create Work Order")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should pre-populate fields from selected repository", async () => {
|
||||
render(<CreateWorkOrderModal open={true} onOpenChange={vi.fn()} selectedRepositoryId="repo-1" />, {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Wait for repository data to be populated
|
||||
await waitFor(() => {
|
||||
const urlInput = screen.getByLabelText("Repository URL") as HTMLInputElement;
|
||||
expect(urlInput.value).toBe("https://github.com/test/repo");
|
||||
});
|
||||
});
|
||||
|
||||
it("should show validation error for empty request", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<CreateWorkOrderModal open={true} onOpenChange={vi.fn()} />, { wrapper });
|
||||
|
||||
// Try to submit without filling required fields
|
||||
const submitButton = screen.getByText("Create Work Order");
|
||||
await user.click(submitButton);
|
||||
|
||||
// Should show validation error
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Request must be at least 10 characters/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should disable commit and PR steps when execute is not selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<CreateWorkOrderModal open={true} onOpenChange={vi.fn()} />, { wrapper });
|
||||
|
||||
// Uncheck execute step
|
||||
const executeCheckbox = screen.getByLabelText("Execute");
|
||||
await user.click(executeCheckbox);
|
||||
|
||||
// Commit and PR should be disabled
|
||||
const commitCheckbox = screen.getByLabelText("Commit Changes") as HTMLInputElement;
|
||||
const prCheckbox = screen.getByLabelText("Create Pull Request") as HTMLInputElement;
|
||||
|
||||
expect(commitCheckbox).toBeDisabled();
|
||||
expect(prCheckbox).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should have accessible form labels", () => {
|
||||
render(<CreateWorkOrderModal open={true} onOpenChange={vi.fn()} />, { wrapper });
|
||||
|
||||
expect(screen.getByLabelText("Repository")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Repository URL")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Work Request")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* RepositoryCard Component Tests
|
||||
*
|
||||
* Tests for repository card rendering and interactions.
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ConfiguredRepository } from "../../types/repository";
|
||||
import { RepositoryCard } from "../RepositoryCard";
|
||||
|
||||
const mockRepository: ConfiguredRepository = {
|
||||
id: "repo-1",
|
||||
repository_url: "https://github.com/test/repository",
|
||||
display_name: "test/repository",
|
||||
owner: "test",
|
||||
default_branch: "main",
|
||||
is_verified: true,
|
||||
last_verified_at: "2024-01-01T00:00:00Z",
|
||||
default_sandbox_type: "git_worktree",
|
||||
default_commands: ["create-branch", "planning", "execute"],
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
describe("RepositoryCard", () => {
|
||||
it("should render repository name and URL", () => {
|
||||
render(<RepositoryCard repository={mockRepository} stats={{ total: 5, active: 2, done: 3 }} />);
|
||||
|
||||
expect(screen.getByText("test/repository")).toBeInTheDocument();
|
||||
expect(screen.getByText(/test\/repository/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display work order stats", () => {
|
||||
render(<RepositoryCard repository={mockRepository} stats={{ total: 5, active: 2, done: 3 }} />);
|
||||
|
||||
expect(screen.getByLabelText("5 total work orders")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("2 active work orders")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("3 completed work orders")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show verified status when repository is verified", () => {
|
||||
render(<RepositoryCard repository={mockRepository} stats={{ total: 0, active: 0, done: 0 }} />);
|
||||
|
||||
expect(screen.getByText("✓ Verified")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onSelect when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
render(<RepositoryCard repository={mockRepository} onSelect={onSelect} stats={{ total: 0, active: 0, done: 0 }} />);
|
||||
|
||||
const card = screen.getByRole("button", { name: /test\/repository/i });
|
||||
await user.click(card);
|
||||
|
||||
expect(onSelect).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should show pin indicator when isPinned is true", () => {
|
||||
render(<RepositoryCard repository={mockRepository} isPinned={true} stats={{ total: 0, active: 0, done: 0 }} />);
|
||||
|
||||
expect(screen.getByText("Pinned")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onPin when pin button clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPin = vi.fn();
|
||||
|
||||
render(<RepositoryCard repository={mockRepository} onPin={onPin} stats={{ total: 0, active: 0, done: 0 }} />);
|
||||
|
||||
const pinButton = screen.getByLabelText("Pin repository");
|
||||
await user.click(pinButton);
|
||||
|
||||
expect(onPin).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should call onDelete when delete button clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDelete = vi.fn();
|
||||
|
||||
render(<RepositoryCard repository={mockRepository} onDelete={onDelete} stats={{ total: 0, active: 0, done: 0 }} />);
|
||||
|
||||
const deleteButton = screen.getByLabelText("Delete repository");
|
||||
await user.click(deleteButton);
|
||||
|
||||
expect(onDelete).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should support keyboard navigation (Enter key)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
render(<RepositoryCard repository={mockRepository} onSelect={onSelect} stats={{ total: 0, active: 0, done: 0 }} />);
|
||||
|
||||
const card = screen.getByRole("button", { name: /test\/repository/i });
|
||||
card.focus();
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
expect(onSelect).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should have proper ARIA attributes", () => {
|
||||
render(<RepositoryCard repository={mockRepository} isSelected={true} stats={{ total: 0, active: 0, done: 0 }} />);
|
||||
|
||||
const card = screen.getByRole("button");
|
||||
expect(card).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ vi.mock("../../services/agentWorkOrdersService", () => ({
|
||||
getWorkOrder: vi.fn(),
|
||||
getStepHistory: vi.fn(),
|
||||
createWorkOrder: vi.fn(),
|
||||
startWorkOrder: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -262,3 +263,172 @@ describe("useCreateWorkOrder", () => {
|
||||
expect(result.current.data).toEqual(mockCreated);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useStartWorkOrder", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should start a pending work order with optimistic update", async () => {
|
||||
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
|
||||
const { useStartWorkOrder } = await import("../useAgentWorkOrderQueries");
|
||||
|
||||
const mockPendingWorkOrder = {
|
||||
agent_work_order_id: "wo-123",
|
||||
repository_url: "https://github.com/test/repo",
|
||||
sandbox_identifier: "sandbox-123",
|
||||
git_branch_name: null,
|
||||
agent_session_id: null,
|
||||
sandbox_type: "git_worktree" as const,
|
||||
github_issue_number: null,
|
||||
status: "pending" as const,
|
||||
current_phase: null,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
github_pull_request_url: null,
|
||||
git_commit_count: 0,
|
||||
git_files_changed: 0,
|
||||
error_message: null,
|
||||
};
|
||||
|
||||
const mockRunningWorkOrder = {
|
||||
...mockPendingWorkOrder,
|
||||
status: "running" as const,
|
||||
updated_at: "2024-01-01T00:01:00Z",
|
||||
};
|
||||
|
||||
// Set initial data in cache
|
||||
queryClient.setQueryData(agentWorkOrderKeys.detail("wo-123"), mockPendingWorkOrder);
|
||||
queryClient.setQueryData(agentWorkOrderKeys.lists(), [mockPendingWorkOrder]);
|
||||
|
||||
vi.mocked(agentWorkOrdersService.startWorkOrder).mockResolvedValue(mockRunningWorkOrder);
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStartWorkOrder(), { wrapper });
|
||||
|
||||
result.current.mutate("wo-123");
|
||||
|
||||
// Verify optimistic update happened immediately
|
||||
await waitFor(() => {
|
||||
const data = queryClient.getQueryData(agentWorkOrderKeys.detail("wo-123"));
|
||||
expect((data as any)?.status).toBe("running");
|
||||
});
|
||||
|
||||
// Wait for mutation to complete
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(agentWorkOrdersService.startWorkOrder).toHaveBeenCalledWith("wo-123");
|
||||
expect(result.current.data).toEqual(mockRunningWorkOrder);
|
||||
});
|
||||
|
||||
it("should rollback on error", async () => {
|
||||
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
|
||||
const { useStartWorkOrder } = await import("../useAgentWorkOrderQueries");
|
||||
|
||||
const mockPendingWorkOrder = {
|
||||
agent_work_order_id: "wo-123",
|
||||
repository_url: "https://github.com/test/repo",
|
||||
sandbox_identifier: "sandbox-123",
|
||||
git_branch_name: null,
|
||||
agent_session_id: null,
|
||||
sandbox_type: "git_worktree" as const,
|
||||
github_issue_number: null,
|
||||
status: "pending" as const,
|
||||
current_phase: null,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
github_pull_request_url: null,
|
||||
git_commit_count: 0,
|
||||
git_files_changed: 0,
|
||||
error_message: null,
|
||||
};
|
||||
|
||||
// Set initial data in cache
|
||||
queryClient.setQueryData(agentWorkOrderKeys.detail("wo-123"), mockPendingWorkOrder);
|
||||
queryClient.setQueryData(agentWorkOrderKeys.lists(), [mockPendingWorkOrder]);
|
||||
|
||||
const error = new Error("Failed to start work order");
|
||||
vi.mocked(agentWorkOrdersService.startWorkOrder).mockRejectedValue(error);
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStartWorkOrder(), { wrapper });
|
||||
|
||||
result.current.mutate("wo-123");
|
||||
|
||||
// Wait for mutation to fail
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
// Verify data was rolled back to pending status
|
||||
const data = queryClient.getQueryData(agentWorkOrderKeys.detail("wo-123"));
|
||||
expect((data as any)?.status).toBe("pending");
|
||||
|
||||
const listData = queryClient.getQueryData(agentWorkOrderKeys.lists()) as any[];
|
||||
expect(listData[0]?.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("should update both detail and list caches on success", async () => {
|
||||
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
|
||||
const { useStartWorkOrder } = await import("../useAgentWorkOrderQueries");
|
||||
|
||||
const mockPendingWorkOrder = {
|
||||
agent_work_order_id: "wo-123",
|
||||
repository_url: "https://github.com/test/repo",
|
||||
sandbox_identifier: "sandbox-123",
|
||||
git_branch_name: null,
|
||||
agent_session_id: null,
|
||||
sandbox_type: "git_worktree" as const,
|
||||
github_issue_number: null,
|
||||
status: "pending" as const,
|
||||
current_phase: null,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
github_pull_request_url: null,
|
||||
git_commit_count: 0,
|
||||
git_files_changed: 0,
|
||||
error_message: null,
|
||||
};
|
||||
|
||||
const mockRunningWorkOrder = {
|
||||
...mockPendingWorkOrder,
|
||||
status: "running" as const,
|
||||
updated_at: "2024-01-01T00:01:00Z",
|
||||
};
|
||||
|
||||
// Set initial data in cache
|
||||
queryClient.setQueryData(agentWorkOrderKeys.detail("wo-123"), mockPendingWorkOrder);
|
||||
queryClient.setQueryData(agentWorkOrderKeys.lists(), [mockPendingWorkOrder]);
|
||||
|
||||
vi.mocked(agentWorkOrdersService.startWorkOrder).mockResolvedValue(mockRunningWorkOrder);
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStartWorkOrder(), { wrapper });
|
||||
|
||||
result.current.mutate("wo-123");
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
// Verify both detail and list caches updated
|
||||
const detailData = queryClient.getQueryData(agentWorkOrderKeys.detail("wo-123"));
|
||||
expect((detailData as any)?.status).toBe("running");
|
||||
|
||||
const listData = queryClient.getQueryData(agentWorkOrderKeys.lists()) as any[];
|
||||
expect(listData[0]?.status).toBe("running");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* Repository Query Hooks Tests
|
||||
*
|
||||
* Unit tests for repository query hooks.
|
||||
* Mocks repositoryService and query patterns.
|
||||
*/
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConfiguredRepository, CreateRepositoryRequest, UpdateRepositoryRequest } from "../../types/repository";
|
||||
import {
|
||||
repositoryKeys,
|
||||
useCreateRepository,
|
||||
useDeleteRepository,
|
||||
useRepositories,
|
||||
useUpdateRepository,
|
||||
useVerifyRepository,
|
||||
} from "../useRepositoryQueries";
|
||||
|
||||
// Mock the repository service
|
||||
vi.mock("../../services/repositoryService", () => ({
|
||||
repositoryService: {
|
||||
listRepositories: vi.fn(),
|
||||
createRepository: vi.fn(),
|
||||
updateRepository: vi.fn(),
|
||||
deleteRepository: vi.fn(),
|
||||
verifyRepositoryAccess: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock shared patterns
|
||||
vi.mock("@/features/shared/config/queryPatterns", () => ({
|
||||
DISABLED_QUERY_KEY: ["disabled"] as const,
|
||||
STALE_TIMES: {
|
||||
instant: 0,
|
||||
realtime: 3000,
|
||||
frequent: 5000,
|
||||
normal: 30000,
|
||||
rare: 300000,
|
||||
static: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock toast hook
|
||||
vi.mock("@/features/ui/hooks/useToast", () => ({
|
||||
useToast: () => ({
|
||||
showToast: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Import after mocking
|
||||
import { repositoryService } from "../../services/repositoryService";
|
||||
|
||||
describe("useRepositoryQueries", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create fresh query client for each test
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const createWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
describe("repositoryKeys", () => {
|
||||
it("should generate correct query keys", () => {
|
||||
expect(repositoryKeys.all).toEqual(["repositories"]);
|
||||
expect(repositoryKeys.lists()).toEqual(["repositories", "list"]);
|
||||
expect(repositoryKeys.detail("repo-1")).toEqual(["repositories", "detail", "repo-1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useRepositories", () => {
|
||||
it("should fetch repositories list", async () => {
|
||||
const mockRepositories: ConfiguredRepository[] = [
|
||||
{
|
||||
id: "repo-1",
|
||||
repository_url: "https://github.com/test/repo",
|
||||
display_name: "test/repo",
|
||||
owner: "test",
|
||||
default_branch: "main",
|
||||
is_verified: true,
|
||||
last_verified_at: "2024-01-01T00:00:00Z",
|
||||
default_sandbox_type: "git_worktree",
|
||||
default_commands: ["create-branch", "planning", "execute"],
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(repositoryService.listRepositories).mockResolvedValue(mockRepositories);
|
||||
|
||||
const { result } = renderHook(() => useRepositories(), { wrapper: createWrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(result.current.data).toEqual(mockRepositories);
|
||||
expect(repositoryService.listRepositories).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should handle empty repository list", async () => {
|
||||
vi.mocked(repositoryService.listRepositories).mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => useRepositories(), { wrapper: createWrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(result.current.data).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle errors", async () => {
|
||||
const error = new Error("Network error");
|
||||
vi.mocked(repositoryService.listRepositories).mockRejectedValue(error);
|
||||
|
||||
const { result } = renderHook(() => useRepositories(), { wrapper: createWrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
expect(result.current.error).toEqual(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCreateRepository", () => {
|
||||
it("should create repository with optimistic update", async () => {
|
||||
const request: CreateRepositoryRequest = {
|
||||
repository_url: "https://github.com/test/repo",
|
||||
verify: true,
|
||||
};
|
||||
|
||||
const mockResponse: ConfiguredRepository = {
|
||||
id: "repo-1",
|
||||
repository_url: "https://github.com/test/repo",
|
||||
display_name: "test/repo",
|
||||
owner: "test",
|
||||
default_branch: "main",
|
||||
is_verified: true,
|
||||
last_verified_at: "2024-01-01T00:00:00Z",
|
||||
default_sandbox_type: "git_worktree",
|
||||
default_commands: ["create-branch", "planning", "execute", "commit", "create-pr"],
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
vi.mocked(repositoryService.createRepository).mockResolvedValue(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useCreateRepository(), { wrapper: createWrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync(request);
|
||||
});
|
||||
|
||||
expect(repositoryService.createRepository).toHaveBeenCalledWith(request);
|
||||
});
|
||||
|
||||
it("should rollback on error", async () => {
|
||||
const request: CreateRepositoryRequest = {
|
||||
repository_url: "https://github.com/test/repo",
|
||||
};
|
||||
|
||||
const error = new Error("Creation failed");
|
||||
vi.mocked(repositoryService.createRepository).mockRejectedValue(error);
|
||||
|
||||
// Set initial data
|
||||
queryClient.setQueryData(repositoryKeys.lists(), []);
|
||||
|
||||
const { result } = renderHook(() => useCreateRepository(), { wrapper: createWrapper });
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.mutateAsync(request);
|
||||
} catch {
|
||||
// Expected error
|
||||
}
|
||||
});
|
||||
|
||||
// Should rollback to empty array
|
||||
const data = queryClient.getQueryData(repositoryKeys.lists());
|
||||
expect(data).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useUpdateRepository", () => {
|
||||
it("should update repository with optimistic update", async () => {
|
||||
const id = "repo-1";
|
||||
const request: UpdateRepositoryRequest = {
|
||||
default_sandbox_type: "git_branch",
|
||||
};
|
||||
|
||||
const mockResponse: ConfiguredRepository = {
|
||||
id: "repo-1",
|
||||
repository_url: "https://github.com/test/repo",
|
||||
display_name: "test/repo",
|
||||
owner: "test",
|
||||
default_branch: "main",
|
||||
is_verified: true,
|
||||
last_verified_at: "2024-01-01T00:00:00Z",
|
||||
default_sandbox_type: "git_branch",
|
||||
default_commands: ["create-branch", "planning", "execute"],
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-02T00:00:00Z",
|
||||
};
|
||||
|
||||
vi.mocked(repositoryService.updateRepository).mockResolvedValue(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useUpdateRepository(), { wrapper: createWrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({ id, request });
|
||||
});
|
||||
|
||||
expect(repositoryService.updateRepository).toHaveBeenCalledWith(id, request);
|
||||
});
|
||||
|
||||
it("should rollback on error", async () => {
|
||||
const initialRepo: ConfiguredRepository = {
|
||||
id: "repo-1",
|
||||
repository_url: "https://github.com/test/repo",
|
||||
display_name: "test/repo",
|
||||
owner: "test",
|
||||
default_branch: "main",
|
||||
is_verified: true,
|
||||
last_verified_at: "2024-01-01T00:00:00Z",
|
||||
default_sandbox_type: "git_worktree",
|
||||
default_commands: ["create-branch", "planning", "execute"],
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
// Set initial data
|
||||
queryClient.setQueryData(repositoryKeys.lists(), [initialRepo]);
|
||||
|
||||
const error = new Error("Update failed");
|
||||
vi.mocked(repositoryService.updateRepository).mockRejectedValue(error);
|
||||
|
||||
const { result } = renderHook(() => useUpdateRepository(), { wrapper: createWrapper });
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.mutateAsync({
|
||||
id: "repo-1",
|
||||
request: { default_sandbox_type: "git_branch" },
|
||||
});
|
||||
} catch {
|
||||
// Expected error
|
||||
}
|
||||
});
|
||||
|
||||
// Should rollback to initial data
|
||||
const data = queryClient.getQueryData(repositoryKeys.lists());
|
||||
expect(data).toEqual([initialRepo]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDeleteRepository", () => {
|
||||
it("should delete repository with optimistic removal", async () => {
|
||||
const initialRepo: ConfiguredRepository = {
|
||||
id: "repo-1",
|
||||
repository_url: "https://github.com/test/repo",
|
||||
display_name: "test/repo",
|
||||
owner: "test",
|
||||
default_branch: "main",
|
||||
is_verified: true,
|
||||
last_verified_at: "2024-01-01T00:00:00Z",
|
||||
default_sandbox_type: "git_worktree",
|
||||
default_commands: ["create-branch", "planning", "execute"],
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
// Set initial data
|
||||
queryClient.setQueryData(repositoryKeys.lists(), [initialRepo]);
|
||||
|
||||
vi.mocked(repositoryService.deleteRepository).mockResolvedValue();
|
||||
|
||||
const { result } = renderHook(() => useDeleteRepository(), { wrapper: createWrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync("repo-1");
|
||||
});
|
||||
|
||||
expect(repositoryService.deleteRepository).toHaveBeenCalledWith("repo-1");
|
||||
});
|
||||
|
||||
it("should rollback on error", async () => {
|
||||
const initialRepo: ConfiguredRepository = {
|
||||
id: "repo-1",
|
||||
repository_url: "https://github.com/test/repo",
|
||||
display_name: "test/repo",
|
||||
owner: "test",
|
||||
default_branch: "main",
|
||||
is_verified: true,
|
||||
last_verified_at: "2024-01-01T00:00:00Z",
|
||||
default_sandbox_type: "git_worktree",
|
||||
default_commands: ["create-branch", "planning", "execute"],
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
// Set initial data
|
||||
queryClient.setQueryData(repositoryKeys.lists(), [initialRepo]);
|
||||
|
||||
const error = new Error("Delete failed");
|
||||
vi.mocked(repositoryService.deleteRepository).mockRejectedValue(error);
|
||||
|
||||
const { result } = renderHook(() => useDeleteRepository(), { wrapper: createWrapper });
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.mutateAsync("repo-1");
|
||||
} catch {
|
||||
// Expected error
|
||||
}
|
||||
});
|
||||
|
||||
// Should rollback to initial data
|
||||
const data = queryClient.getQueryData(repositoryKeys.lists());
|
||||
expect(data).toEqual([initialRepo]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useVerifyRepository", () => {
|
||||
it("should verify repository and invalidate queries", async () => {
|
||||
const mockResponse = {
|
||||
is_accessible: true,
|
||||
repository_id: "repo-1",
|
||||
};
|
||||
|
||||
vi.mocked(repositoryService.verifyRepositoryAccess).mockResolvedValue(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useVerifyRepository(), { wrapper: createWrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync("repo-1");
|
||||
});
|
||||
|
||||
expect(repositoryService.verifyRepositoryAccess).toHaveBeenCalledWith("repo-1");
|
||||
});
|
||||
|
||||
it("should handle inaccessible repository", async () => {
|
||||
const mockResponse = {
|
||||
is_accessible: false,
|
||||
repository_id: "repo-1",
|
||||
};
|
||||
|
||||
vi.mocked(repositoryService.verifyRepositoryAccess).mockResolvedValue(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useVerifyRepository(), { wrapper: createWrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync("repo-1");
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it("should handle verification errors", async () => {
|
||||
const error = new Error("GitHub API error");
|
||||
vi.mocked(repositoryService.verifyRepositoryAccess).mockRejectedValue(error);
|
||||
|
||||
const { result } = renderHook(() => useVerifyRepository(), { wrapper: createWrapper });
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.mutateAsync("repo-1");
|
||||
} catch {
|
||||
// Expected error
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
* Follows the pattern established in useProjectQueries.ts
|
||||
*/
|
||||
|
||||
import { type UseQueryResult, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { DISABLED_QUERY_KEY, STALE_TIMES } from "@/features/shared/config/queryPatterns";
|
||||
import { useSmartPolling } from "@/features/shared/hooks/useSmartPolling";
|
||||
import { agentWorkOrdersService } from "../services/agentWorkOrdersService";
|
||||
@@ -31,22 +31,17 @@ export const agentWorkOrderKeys = {
|
||||
* @param statusFilter - Optional status to filter work orders
|
||||
* @returns Query result with work orders array
|
||||
*/
|
||||
export function useWorkOrders(statusFilter?: AgentWorkOrderStatus): UseQueryResult<AgentWorkOrder[], Error> {
|
||||
const refetchInterval = useSmartPolling({
|
||||
baseInterval: 3000,
|
||||
enabled: true,
|
||||
});
|
||||
export function useWorkOrders(statusFilter?: AgentWorkOrderStatus) {
|
||||
const polling = useSmartPolling(3000);
|
||||
|
||||
return useQuery({
|
||||
return useQuery<AgentWorkOrder[], Error>({
|
||||
queryKey: agentWorkOrderKeys.list(statusFilter),
|
||||
queryFn: () => agentWorkOrdersService.listWorkOrders(statusFilter),
|
||||
staleTime: STALE_TIMES.instant,
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data as AgentWorkOrder[] | undefined;
|
||||
const hasActiveWorkOrders = data?.some(
|
||||
(wo) => wo.status === "running" || wo.status === "pending"
|
||||
);
|
||||
return hasActiveWorkOrders ? refetchInterval : false;
|
||||
const hasActiveWorkOrders = data?.some((wo) => wo.status === "running" || wo.status === "pending");
|
||||
return hasActiveWorkOrders ? polling.refetchInterval : false;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -58,13 +53,10 @@ export function useWorkOrders(statusFilter?: AgentWorkOrderStatus): UseQueryResu
|
||||
* @param id - Work order ID (undefined disables query)
|
||||
* @returns Query result with work order data
|
||||
*/
|
||||
export function useWorkOrder(id: string | undefined): UseQueryResult<AgentWorkOrder, Error> {
|
||||
const refetchInterval = useSmartPolling({
|
||||
baseInterval: 3000,
|
||||
enabled: true,
|
||||
});
|
||||
export function useWorkOrder(id: string | undefined) {
|
||||
const polling = useSmartPolling(3000);
|
||||
|
||||
return useQuery({
|
||||
return useQuery<AgentWorkOrder, Error>({
|
||||
queryKey: id ? agentWorkOrderKeys.detail(id) : DISABLED_QUERY_KEY,
|
||||
queryFn: () => (id ? agentWorkOrdersService.getWorkOrder(id) : Promise.reject(new Error("No ID provided"))),
|
||||
enabled: !!id,
|
||||
@@ -72,7 +64,7 @@ export function useWorkOrder(id: string | undefined): UseQueryResult<AgentWorkOr
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data as AgentWorkOrder | undefined;
|
||||
if (data?.status === "running" || data?.status === "pending") {
|
||||
return refetchInterval;
|
||||
return polling.refetchInterval;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
@@ -86,13 +78,10 @@ export function useWorkOrder(id: string | undefined): UseQueryResult<AgentWorkOr
|
||||
* @param workOrderId - Work order ID (undefined disables query)
|
||||
* @returns Query result with step history
|
||||
*/
|
||||
export function useStepHistory(workOrderId: string | undefined): UseQueryResult<StepHistory, Error> {
|
||||
const refetchInterval = useSmartPolling({
|
||||
baseInterval: 3000,
|
||||
enabled: true,
|
||||
});
|
||||
export function useStepHistory(workOrderId: string | undefined) {
|
||||
const polling = useSmartPolling(3000);
|
||||
|
||||
return useQuery({
|
||||
return useQuery<StepHistory, Error>({
|
||||
queryKey: workOrderId ? agentWorkOrderKeys.stepHistory(workOrderId) : DISABLED_QUERY_KEY,
|
||||
queryFn: () =>
|
||||
workOrderId ? agentWorkOrdersService.getStepHistory(workOrderId) : Promise.reject(new Error("No ID provided")),
|
||||
@@ -104,7 +93,7 @@ export function useStepHistory(workOrderId: string | undefined): UseQueryResult<
|
||||
if (lastStep?.step === "create-pr" && lastStep?.success) {
|
||||
return false;
|
||||
}
|
||||
return refetchInterval;
|
||||
return polling.refetchInterval;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -131,3 +120,73 @@ export function useCreateWorkOrder() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to start a pending work order (transition from pending to running)
|
||||
* Implements optimistic update to immediately show running state in UI
|
||||
* Triggers backend execution by updating status to "running"
|
||||
*
|
||||
* @returns Mutation object with mutate function
|
||||
*/
|
||||
export function useStartWorkOrder() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
AgentWorkOrder,
|
||||
Error,
|
||||
string,
|
||||
{ previousWorkOrder?: AgentWorkOrder; previousList?: AgentWorkOrder[] }
|
||||
>({
|
||||
mutationFn: (id: string) => agentWorkOrdersService.startWorkOrder(id),
|
||||
|
||||
onMutate: async (id) => {
|
||||
// Cancel any outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: agentWorkOrderKeys.detail(id) });
|
||||
await queryClient.cancelQueries({ queryKey: agentWorkOrderKeys.lists() });
|
||||
|
||||
// Snapshot the previous values
|
||||
const previousWorkOrder = queryClient.getQueryData<AgentWorkOrder>(agentWorkOrderKeys.detail(id));
|
||||
const previousList = queryClient.getQueryData<AgentWorkOrder[]>(agentWorkOrderKeys.lists());
|
||||
|
||||
// Optimistically update the work order status to "running"
|
||||
if (previousWorkOrder) {
|
||||
const optimisticWorkOrder = {
|
||||
...previousWorkOrder,
|
||||
status: "running" as AgentWorkOrderStatus,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
queryClient.setQueryData(agentWorkOrderKeys.detail(id), optimisticWorkOrder);
|
||||
|
||||
// Update in list as well if present
|
||||
queryClient.setQueryData<AgentWorkOrder[]>(agentWorkOrderKeys.lists(), (old) => {
|
||||
if (!old) return old;
|
||||
return old.map((wo) => (wo.agent_work_order_id === id ? optimisticWorkOrder : wo));
|
||||
});
|
||||
}
|
||||
|
||||
return { previousWorkOrder, previousList };
|
||||
},
|
||||
|
||||
onError: (error, id, context) => {
|
||||
console.error("Failed to start work order:", error);
|
||||
|
||||
// Rollback on error
|
||||
if (context?.previousWorkOrder) {
|
||||
queryClient.setQueryData(agentWorkOrderKeys.detail(id), context.previousWorkOrder);
|
||||
}
|
||||
if (context?.previousList) {
|
||||
queryClient.setQueryData(agentWorkOrderKeys.lists(), context.previousList);
|
||||
}
|
||||
},
|
||||
|
||||
onSuccess: (data, id) => {
|
||||
// Replace optimistic update with server response
|
||||
queryClient.setQueryData(agentWorkOrderKeys.detail(id), data);
|
||||
queryClient.setQueryData<AgentWorkOrder[]>(agentWorkOrderKeys.lists(), (old) => {
|
||||
if (!old) return [data];
|
||||
return old.map((wo) => (wo.agent_work_order_id === id ? data : wo));
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -103,7 +103,9 @@ export function useLogStats(logs: LogEntry[]): LogStats {
|
||||
// Check for workflow lifecycle events
|
||||
const hasStarted = logs.some((log) => log.event === "workflow_started" || log.event === "step_started");
|
||||
|
||||
const hasCompleted = logs.some((log) => log.event === "workflow_completed" || log.event === "agent_work_order_completed");
|
||||
const hasCompleted = logs.some(
|
||||
(log) => log.event === "workflow_completed" || log.event === "agent_work_order_completed",
|
||||
);
|
||||
|
||||
const hasFailed = logs.some(
|
||||
(log) => log.event === "workflow_failed" || log.event === "agent_work_order_failed" || log.level === "error",
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Repository Query Hooks
|
||||
*
|
||||
* TanStack Query hooks for repository management.
|
||||
* Follows patterns from QUERY_PATTERNS.md with query key factories and optimistic updates.
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { DISABLED_QUERY_KEY, STALE_TIMES } from "@/features/shared/config/queryPatterns";
|
||||
import { useToast } from "@/features/shared/hooks/useToast";
|
||||
import { createOptimisticEntity, replaceOptimisticEntity } from "@/features/shared/utils/optimistic";
|
||||
import { repositoryService } from "../services/repositoryService";
|
||||
import type { ConfiguredRepository, CreateRepositoryRequest, UpdateRepositoryRequest } from "../types/repository";
|
||||
|
||||
/**
|
||||
* Query key factory for repositories
|
||||
* Follows the pattern: domain > scope > identifier
|
||||
*/
|
||||
export const repositoryKeys = {
|
||||
all: ["repositories"] as const,
|
||||
lists: () => [...repositoryKeys.all, "list"] as const,
|
||||
detail: (id: string) => [...repositoryKeys.all, "detail", id] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* List all configured repositories
|
||||
* @returns Query result with array of repositories
|
||||
*/
|
||||
export function useRepositories() {
|
||||
return useQuery<ConfiguredRepository[]>({
|
||||
queryKey: repositoryKeys.lists(),
|
||||
queryFn: () => repositoryService.listRepositories(),
|
||||
staleTime: STALE_TIMES.normal, // 30 seconds
|
||||
refetchOnWindowFocus: true, // Refetch when tab gains focus (ETag makes this cheap)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single repository by ID
|
||||
* @param id - Repository ID to fetch
|
||||
* @returns Query result with repository detail
|
||||
*/
|
||||
export function useRepository(id: string | undefined) {
|
||||
return useQuery<ConfiguredRepository>({
|
||||
queryKey: id ? repositoryKeys.detail(id) : DISABLED_QUERY_KEY,
|
||||
queryFn: () => {
|
||||
if (!id) return Promise.reject("No repository ID provided");
|
||||
// Note: Backend doesn't have a get-by-id endpoint yet, so we fetch from list
|
||||
return repositoryService.listRepositories().then((repos) => {
|
||||
const repo = repos.find((r) => r.id === id);
|
||||
if (!repo) throw new Error("Repository not found");
|
||||
return repo;
|
||||
});
|
||||
},
|
||||
enabled: !!id,
|
||||
staleTime: STALE_TIMES.normal,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new configured repository with optimistic updates
|
||||
* @returns Mutation result for creating repository
|
||||
*/
|
||||
export function useCreateRepository() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
|
||||
return useMutation<
|
||||
ConfiguredRepository,
|
||||
Error,
|
||||
CreateRepositoryRequest,
|
||||
{ previousRepositories?: ConfiguredRepository[]; optimisticId: string }
|
||||
>({
|
||||
mutationFn: (request: CreateRepositoryRequest) => repositoryService.createRepository(request),
|
||||
onMutate: async (newRepositoryData) => {
|
||||
// Cancel any outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: repositoryKeys.lists() });
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousRepositories = queryClient.getQueryData<ConfiguredRepository[]>(repositoryKeys.lists());
|
||||
|
||||
// Create optimistic repository with stable ID
|
||||
const optimisticRepository = createOptimisticEntity<ConfiguredRepository>({
|
||||
repository_url: newRepositoryData.repository_url,
|
||||
display_name: null,
|
||||
owner: null,
|
||||
default_branch: null,
|
||||
is_verified: false,
|
||||
last_verified_at: null,
|
||||
default_sandbox_type: "git_worktree",
|
||||
default_commands: ["create-branch", "planning", "execute", "commit", "create-pr"],
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Optimistically add the new repository
|
||||
queryClient.setQueryData<ConfiguredRepository[]>(repositoryKeys.lists(), (old) => {
|
||||
if (!old) return [optimisticRepository];
|
||||
// Add new repository at the beginning of the list
|
||||
return [optimisticRepository, ...old];
|
||||
});
|
||||
|
||||
return { previousRepositories, optimisticId: optimisticRepository._localId };
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error("Failed to create repository:", error, { variables });
|
||||
|
||||
// Rollback on error
|
||||
if (context?.previousRepositories) {
|
||||
queryClient.setQueryData(repositoryKeys.lists(), context.previousRepositories);
|
||||
}
|
||||
|
||||
showToast(`Failed to create repository: ${errorMessage}`, "error");
|
||||
},
|
||||
onSuccess: (response, _variables, context) => {
|
||||
// Replace optimistic entity with real response
|
||||
queryClient.setQueryData<ConfiguredRepository[]>(repositoryKeys.lists(), (old) => {
|
||||
if (!old) return [response];
|
||||
return replaceOptimisticEntity(old, context?.optimisticId, response);
|
||||
});
|
||||
|
||||
showToast("Repository created successfully", "success");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing repository with optimistic updates
|
||||
* @returns Mutation result for updating repository
|
||||
*/
|
||||
export function useUpdateRepository() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
|
||||
return useMutation<
|
||||
ConfiguredRepository,
|
||||
Error,
|
||||
{ id: string; request: UpdateRepositoryRequest },
|
||||
{ previousRepositories?: ConfiguredRepository[] }
|
||||
>({
|
||||
mutationFn: ({ id, request }) => repositoryService.updateRepository(id, request),
|
||||
onMutate: async ({ id, request }) => {
|
||||
// Cancel any outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: repositoryKeys.lists() });
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousRepositories = queryClient.getQueryData<ConfiguredRepository[]>(repositoryKeys.lists());
|
||||
|
||||
// Optimistically update the repository
|
||||
queryClient.setQueryData<ConfiguredRepository[]>(repositoryKeys.lists(), (old) => {
|
||||
if (!old) return old;
|
||||
return old.map((repo) =>
|
||||
repo.id === id
|
||||
? {
|
||||
...repo,
|
||||
...request,
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
: repo,
|
||||
);
|
||||
});
|
||||
|
||||
return { previousRepositories };
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error("Failed to update repository:", error, { variables });
|
||||
|
||||
// Rollback on error
|
||||
if (context?.previousRepositories) {
|
||||
queryClient.setQueryData(repositoryKeys.lists(), context.previousRepositories);
|
||||
}
|
||||
|
||||
showToast(`Failed to update repository: ${errorMessage}`, "error");
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
// Replace with server response
|
||||
queryClient.setQueryData<ConfiguredRepository[]>(repositoryKeys.lists(), (old) => {
|
||||
if (!old) return [response];
|
||||
return old.map((repo) => (repo.id === response.id ? response : repo));
|
||||
});
|
||||
|
||||
showToast("Repository updated successfully", "success");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a repository with optimistic removal
|
||||
* @returns Mutation result for deleting repository
|
||||
*/
|
||||
export function useDeleteRepository() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
|
||||
return useMutation<void, Error, string, { previousRepositories?: ConfiguredRepository[] }>({
|
||||
mutationFn: (id: string) => repositoryService.deleteRepository(id),
|
||||
onMutate: async (id) => {
|
||||
// Cancel any outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: repositoryKeys.lists() });
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousRepositories = queryClient.getQueryData<ConfiguredRepository[]>(repositoryKeys.lists());
|
||||
|
||||
// Optimistically remove the repository
|
||||
queryClient.setQueryData<ConfiguredRepository[]>(repositoryKeys.lists(), (old) => {
|
||||
if (!old) return old;
|
||||
return old.filter((repo) => repo.id !== id);
|
||||
});
|
||||
|
||||
return { previousRepositories };
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error("Failed to delete repository:", error, { variables });
|
||||
|
||||
// Rollback on error
|
||||
if (context?.previousRepositories) {
|
||||
queryClient.setQueryData(repositoryKeys.lists(), context.previousRepositories);
|
||||
}
|
||||
|
||||
showToast(`Failed to delete repository: ${errorMessage}`, "error");
|
||||
},
|
||||
onSuccess: () => {
|
||||
showToast("Repository deleted successfully", "success");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify repository access and update metadata
|
||||
* @returns Mutation result for verifying repository
|
||||
*/
|
||||
export function useVerifyRepository() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
|
||||
return useMutation<
|
||||
{ is_accessible: boolean; repository_id: string },
|
||||
Error,
|
||||
string,
|
||||
{ previousRepositories?: ConfiguredRepository[] }
|
||||
>({
|
||||
mutationFn: (id: string) => repositoryService.verifyRepositoryAccess(id),
|
||||
onMutate: async (_id) => {
|
||||
// Cancel any outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: repositoryKeys.lists() });
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousRepositories = queryClient.getQueryData<ConfiguredRepository[]>(repositoryKeys.lists());
|
||||
|
||||
return { previousRepositories };
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error("Failed to verify repository:", error, { variables });
|
||||
|
||||
// Rollback on error
|
||||
if (context?.previousRepositories) {
|
||||
queryClient.setQueryData(repositoryKeys.lists(), context.previousRepositories);
|
||||
}
|
||||
|
||||
showToast(`Failed to verify repository: ${errorMessage}`, "error");
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
// Invalidate queries to refetch updated metadata from server
|
||||
queryClient.invalidateQueries({ queryKey: repositoryKeys.lists() });
|
||||
|
||||
if (response.is_accessible) {
|
||||
showToast("Repository verified successfully", "success");
|
||||
} else {
|
||||
showToast("Repository is not accessible", "warning");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Repository Service Tests
|
||||
*
|
||||
* Unit tests for repository service methods.
|
||||
* Mocks callAPIWithETag to test request structure and response handling.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConfiguredRepository, CreateRepositoryRequest, UpdateRepositoryRequest } from "../../types/repository";
|
||||
import { repositoryService } from "../repositoryService";
|
||||
|
||||
// Mock the API client
|
||||
vi.mock("@/features/shared/api/apiClient", () => ({
|
||||
callAPIWithETag: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import after mocking
|
||||
import { callAPIWithETag } from "@/features/shared/api/apiClient";
|
||||
|
||||
describe("repositoryService", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("listRepositories", () => {
|
||||
it("should call GET /api/agent-work-orders/repositories", async () => {
|
||||
const mockRepositories: ConfiguredRepository[] = [
|
||||
{
|
||||
id: "repo-1",
|
||||
repository_url: "https://github.com/test/repo",
|
||||
display_name: "test/repo",
|
||||
owner: "test",
|
||||
default_branch: "main",
|
||||
is_verified: true,
|
||||
last_verified_at: "2024-01-01T00:00:00Z",
|
||||
default_sandbox_type: "git_worktree",
|
||||
default_commands: ["create-branch", "planning", "execute"],
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(callAPIWithETag).mockResolvedValue(mockRepositories);
|
||||
|
||||
const result = await repositoryService.listRepositories();
|
||||
|
||||
expect(callAPIWithETag).toHaveBeenCalledWith("/api/agent-work-orders/repositories", {
|
||||
method: "GET",
|
||||
});
|
||||
expect(result).toEqual(mockRepositories);
|
||||
});
|
||||
|
||||
it("should handle empty repository list", async () => {
|
||||
vi.mocked(callAPIWithETag).mockResolvedValue([]);
|
||||
|
||||
const result = await repositoryService.listRepositories();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should propagate API errors", async () => {
|
||||
const error = new Error("Network error");
|
||||
vi.mocked(callAPIWithETag).mockRejectedValue(error);
|
||||
|
||||
await expect(repositoryService.listRepositories()).rejects.toThrow("Network error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRepository", () => {
|
||||
it("should call POST /api/agent-work-orders/repositories with request body", async () => {
|
||||
const request: CreateRepositoryRequest = {
|
||||
repository_url: "https://github.com/test/repo",
|
||||
verify: true,
|
||||
};
|
||||
|
||||
const mockResponse: ConfiguredRepository = {
|
||||
id: "repo-1",
|
||||
repository_url: "https://github.com/test/repo",
|
||||
display_name: "test/repo",
|
||||
owner: "test",
|
||||
default_branch: "main",
|
||||
is_verified: true,
|
||||
last_verified_at: "2024-01-01T00:00:00Z",
|
||||
default_sandbox_type: "git_worktree",
|
||||
default_commands: ["create-branch", "planning", "execute", "commit", "create-pr"],
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
vi.mocked(callAPIWithETag).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await repositoryService.createRepository(request);
|
||||
|
||||
expect(callAPIWithETag).toHaveBeenCalledWith("/api/agent-work-orders/repositories", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it("should handle creation without verification", async () => {
|
||||
const request: CreateRepositoryRequest = {
|
||||
repository_url: "https://github.com/test/repo",
|
||||
verify: false,
|
||||
};
|
||||
|
||||
const mockResponse: ConfiguredRepository = {
|
||||
id: "repo-1",
|
||||
repository_url: "https://github.com/test/repo",
|
||||
display_name: null,
|
||||
owner: null,
|
||||
default_branch: null,
|
||||
is_verified: false,
|
||||
last_verified_at: null,
|
||||
default_sandbox_type: "git_worktree",
|
||||
default_commands: ["create-branch", "planning", "execute", "commit", "create-pr"],
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
vi.mocked(callAPIWithETag).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await repositoryService.createRepository(request);
|
||||
|
||||
expect(result.is_verified).toBe(false);
|
||||
expect(result.display_name).toBe(null);
|
||||
});
|
||||
|
||||
it("should propagate validation errors", async () => {
|
||||
const error = new Error("Invalid repository URL");
|
||||
vi.mocked(callAPIWithETag).mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
repositoryService.createRepository({
|
||||
repository_url: "invalid-url",
|
||||
}),
|
||||
).rejects.toThrow("Invalid repository URL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateRepository", () => {
|
||||
it("should call PATCH /api/agent-work-orders/repositories/:id with update request", async () => {
|
||||
const id = "repo-1";
|
||||
const request: UpdateRepositoryRequest = {
|
||||
default_sandbox_type: "git_branch",
|
||||
default_commands: ["create-branch", "planning", "execute"],
|
||||
};
|
||||
|
||||
const mockResponse: ConfiguredRepository = {
|
||||
id: "repo-1",
|
||||
repository_url: "https://github.com/test/repo",
|
||||
display_name: "test/repo",
|
||||
owner: "test",
|
||||
default_branch: "main",
|
||||
is_verified: true,
|
||||
last_verified_at: "2024-01-01T00:00:00Z",
|
||||
default_sandbox_type: "git_branch",
|
||||
default_commands: ["create-branch", "planning", "execute"],
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-02T00:00:00Z",
|
||||
};
|
||||
|
||||
vi.mocked(callAPIWithETag).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await repositoryService.updateRepository(id, request);
|
||||
|
||||
expect(callAPIWithETag).toHaveBeenCalledWith(`/api/agent-work-orders/repositories/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it("should handle partial updates", async () => {
|
||||
const id = "repo-1";
|
||||
const request: UpdateRepositoryRequest = {
|
||||
default_sandbox_type: "git_worktree",
|
||||
};
|
||||
|
||||
const mockResponse: ConfiguredRepository = {
|
||||
id: "repo-1",
|
||||
repository_url: "https://github.com/test/repo",
|
||||
display_name: "test/repo",
|
||||
owner: "test",
|
||||
default_branch: "main",
|
||||
is_verified: true,
|
||||
last_verified_at: "2024-01-01T00:00:00Z",
|
||||
default_sandbox_type: "git_worktree",
|
||||
default_commands: ["create-branch", "planning", "execute", "commit", "create-pr"],
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-02T00:00:00Z",
|
||||
};
|
||||
|
||||
vi.mocked(callAPIWithETag).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await repositoryService.updateRepository(id, request);
|
||||
|
||||
expect(result.default_sandbox_type).toBe("git_worktree");
|
||||
});
|
||||
|
||||
it("should handle not found errors", async () => {
|
||||
const error = new Error("Repository not found");
|
||||
vi.mocked(callAPIWithETag).mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
repositoryService.updateRepository("non-existent", {
|
||||
default_sandbox_type: "git_branch",
|
||||
}),
|
||||
).rejects.toThrow("Repository not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteRepository", () => {
|
||||
it("should call DELETE /api/agent-work-orders/repositories/:id", async () => {
|
||||
const id = "repo-1";
|
||||
vi.mocked(callAPIWithETag).mockResolvedValue(undefined);
|
||||
|
||||
await repositoryService.deleteRepository(id);
|
||||
|
||||
expect(callAPIWithETag).toHaveBeenCalledWith(`/api/agent-work-orders/repositories/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle not found errors", async () => {
|
||||
const error = new Error("Repository not found");
|
||||
vi.mocked(callAPIWithETag).mockRejectedValue(error);
|
||||
|
||||
await expect(repositoryService.deleteRepository("non-existent")).rejects.toThrow("Repository not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyRepositoryAccess", () => {
|
||||
it("should call POST /api/agent-work-orders/repositories/:id/verify", async () => {
|
||||
const id = "repo-1";
|
||||
const mockResponse = {
|
||||
is_accessible: true,
|
||||
repository_id: "repo-1",
|
||||
};
|
||||
|
||||
vi.mocked(callAPIWithETag).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await repositoryService.verifyRepositoryAccess(id);
|
||||
|
||||
expect(callAPIWithETag).toHaveBeenCalledWith(`/api/agent-work-orders/repositories/${id}/verify`, {
|
||||
method: "POST",
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it("should handle inaccessible repositories", async () => {
|
||||
const id = "repo-1";
|
||||
const mockResponse = {
|
||||
is_accessible: false,
|
||||
repository_id: "repo-1",
|
||||
};
|
||||
|
||||
vi.mocked(callAPIWithETag).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await repositoryService.verifyRepositoryAccess(id);
|
||||
|
||||
expect(result.is_accessible).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle verification errors", async () => {
|
||||
const error = new Error("GitHub API error");
|
||||
vi.mocked(callAPIWithETag).mockRejectedValue(error);
|
||||
|
||||
await expect(repositoryService.verifyRepositoryAccess("repo-1")).rejects.toThrow("GitHub API error");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -75,4 +75,21 @@ export const agentWorkOrdersService = {
|
||||
const baseUrl = getBaseUrl();
|
||||
return await callAPIWithETag<StepHistory>(`${baseUrl}/${id}/steps`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Start a pending work order (transition from pending to running)
|
||||
* This triggers backend execution by updating the status to "running"
|
||||
*
|
||||
* @param id - The work order ID to start
|
||||
* @returns Promise resolving to the updated work order
|
||||
* @throws Error if work order not found, already running, or request fails
|
||||
*/
|
||||
async startWorkOrder(id: string): Promise<AgentWorkOrder> {
|
||||
const baseUrl = getBaseUrl();
|
||||
// Note: Backend automatically starts execution when status transitions to "running"
|
||||
// This is a conceptual API - actual implementation may vary based on backend
|
||||
return await callAPIWithETag<AgentWorkOrder>(`${baseUrl}/${id}/start`, {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Repository Service
|
||||
*
|
||||
* Service layer for repository CRUD operations.
|
||||
* All methods use callAPIWithETag for automatic ETag caching.
|
||||
*/
|
||||
|
||||
import { callAPIWithETag } from "@/features/shared/api/apiClient";
|
||||
import type { ConfiguredRepository, CreateRepositoryRequest, UpdateRepositoryRequest } from "../types/repository";
|
||||
|
||||
/**
|
||||
* List all configured repositories
|
||||
* @returns Array of configured repositories ordered by created_at DESC
|
||||
*/
|
||||
export async function listRepositories(): Promise<ConfiguredRepository[]> {
|
||||
return callAPIWithETag<ConfiguredRepository[]>("/api/agent-work-orders/repositories", {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new configured repository
|
||||
* @param request - Repository creation request with URL and optional verification
|
||||
* @returns The created repository with metadata
|
||||
*/
|
||||
export async function createRepository(request: CreateRepositoryRequest): Promise<ConfiguredRepository> {
|
||||
return callAPIWithETag<ConfiguredRepository>("/api/agent-work-orders/repositories", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing configured repository
|
||||
* @param id - Repository ID
|
||||
* @param request - Partial update request with fields to modify
|
||||
* @returns The updated repository
|
||||
*/
|
||||
export async function updateRepository(id: string, request: UpdateRepositoryRequest): Promise<ConfiguredRepository> {
|
||||
return callAPIWithETag<ConfiguredRepository>(`/api/agent-work-orders/repositories/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a configured repository
|
||||
* @param id - Repository ID to delete
|
||||
*/
|
||||
export async function deleteRepository(id: string): Promise<void> {
|
||||
await callAPIWithETag<void>(`/api/agent-work-orders/repositories/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify repository access and update metadata
|
||||
* Re-verifies GitHub repository access and updates display_name, owner, default_branch
|
||||
* @param id - Repository ID to verify
|
||||
* @returns Verification result with is_accessible boolean
|
||||
*/
|
||||
export async function verifyRepositoryAccess(id: string): Promise<{ is_accessible: boolean; repository_id: string }> {
|
||||
return callAPIWithETag<{ is_accessible: boolean; repository_id: string }>(
|
||||
`/api/agent-work-orders/repositories/${id}/verify`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Export all methods as named exports and default object
|
||||
export const repositoryService = {
|
||||
listRepositories,
|
||||
createRepository,
|
||||
updateRepository,
|
||||
deleteRepository,
|
||||
verifyRepositoryAccess,
|
||||
};
|
||||
|
||||
export default repositoryService;
|
||||
@@ -96,6 +96,9 @@ export interface CreateAgentWorkOrderRequest {
|
||||
|
||||
/** Optional GitHub issue number to associate with this work order */
|
||||
github_issue_number?: string | null;
|
||||
|
||||
/** Optional configured repository ID for linking work order to repository */
|
||||
repository_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,3 +193,6 @@ export interface LogEntry {
|
||||
* Connection state for SSE stream
|
||||
*/
|
||||
export type SSEConnectionState = "connecting" | "connected" | "disconnected" | "error";
|
||||
|
||||
// Export repository types
|
||||
export type { ConfiguredRepository, CreateRepositoryRequest, UpdateRepositoryRequest } from "./repository";
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Repository Type Definitions
|
||||
*
|
||||
* This module defines TypeScript interfaces for configured repositories.
|
||||
* These types mirror the backend models from python/src/agent_work_orders/models.py ConfiguredRepository
|
||||
*/
|
||||
|
||||
import type { SandboxType, WorkflowStep } from "./index";
|
||||
|
||||
/**
|
||||
* Configured repository with metadata and preferences
|
||||
*
|
||||
* Stores GitHub repository configuration for Agent Work Orders, including
|
||||
* verification status, metadata extracted from GitHub API, and per-repository
|
||||
* preferences for sandbox type and workflow commands.
|
||||
*/
|
||||
export interface ConfiguredRepository {
|
||||
/** Unique UUID identifier for the configured repository */
|
||||
id: string;
|
||||
|
||||
/** GitHub repository URL (https://github.com/owner/repo format) */
|
||||
repository_url: string;
|
||||
|
||||
/** Human-readable repository name (e.g., 'owner/repo-name') */
|
||||
display_name: string | null;
|
||||
|
||||
/** Repository owner/organization name */
|
||||
owner: string | null;
|
||||
|
||||
/** Default branch name (e.g., 'main' or 'master') */
|
||||
default_branch: string | null;
|
||||
|
||||
/** Boolean flag indicating if repository access has been verified */
|
||||
is_verified: boolean;
|
||||
|
||||
/** Timestamp of last successful repository verification */
|
||||
last_verified_at: string | null;
|
||||
|
||||
/** Default sandbox type for work orders */
|
||||
default_sandbox_type: SandboxType;
|
||||
|
||||
/** Default workflow commands for work orders */
|
||||
default_commands: WorkflowStep[];
|
||||
|
||||
/** Timestamp when repository configuration was created */
|
||||
created_at: string;
|
||||
|
||||
/** Timestamp when repository configuration was last updated */
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to create a new configured repository
|
||||
*
|
||||
* Creates a new repository configuration. If verify=True, the system will
|
||||
* call the GitHub API to validate repository access and extract metadata
|
||||
* (display_name, owner, default_branch) before storing.
|
||||
*/
|
||||
export interface CreateRepositoryRequest {
|
||||
/** GitHub repository URL to configure */
|
||||
repository_url: string;
|
||||
|
||||
/** Whether to verify repository access via GitHub API and extract metadata */
|
||||
verify?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to update an existing configured repository
|
||||
*
|
||||
* All fields are optional for partial updates. Only provided fields will be
|
||||
* updated in the database.
|
||||
*/
|
||||
export interface UpdateRepositoryRequest {
|
||||
/** Update the display name for this repository */
|
||||
display_name?: string;
|
||||
|
||||
/** Update the default sandbox type for this repository */
|
||||
default_sandbox_type?: SandboxType;
|
||||
|
||||
/** Update the default workflow commands for this repository */
|
||||
default_commands?: WorkflowStep[];
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Agent Work Order Detail View
|
||||
*
|
||||
* Detailed view of a single agent work order showing progress, step history,
|
||||
* logs, and full metadata.
|
||||
*/
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ChevronDown, ChevronUp, ExternalLink } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Button } from "@/features/ui/primitives/button";
|
||||
import { Card } from "@/features/ui/primitives/card";
|
||||
import { RealTimeStats } from "../components/RealTimeStats";
|
||||
import { StepHistoryCard } from "../components/StepHistoryCard";
|
||||
import { WorkflowStepButton } from "../components/WorkflowStepButton";
|
||||
import { useStepHistory, useWorkOrder } from "../hooks/useAgentWorkOrderQueries";
|
||||
|
||||
export function AgentWorkOrderDetailView() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [expandedSteps, setExpandedSteps] = useState<Set<string>>(new Set());
|
||||
|
||||
const { data: workOrder, isLoading: isLoadingWorkOrder, isError: isErrorWorkOrder } = useWorkOrder(id);
|
||||
const { data: stepHistory, isLoading: isLoadingSteps, isError: isErrorSteps } = useStepHistory(id);
|
||||
|
||||
/**
|
||||
* Toggle step expansion
|
||||
*/
|
||||
const toggleStepExpansion = (stepId: string) => {
|
||||
setExpandedSteps((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(stepId)) {
|
||||
newSet.delete(stepId);
|
||||
} else {
|
||||
newSet.add(stepId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoadingWorkOrder || isLoadingSteps) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 bg-gray-800 rounded w-1/3" />
|
||||
<div className="h-40 bg-gray-800 rounded" />
|
||||
<div className="h-60 bg-gray-800 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isErrorWorkOrder || isErrorSteps || !workOrder || !stepHistory) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-400 mb-4">Failed to load work order</p>
|
||||
<Button onClick={() => navigate("/agent-work-orders")}>Back to List</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const repoName = workOrder.repository_url.split("/").slice(-2).join("/");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Breadcrumb navigation */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/agent-work-orders")}
|
||||
className="text-cyan-600 dark:text-cyan-400 hover:underline"
|
||||
>
|
||||
Work Orders
|
||||
</button>
|
||||
<span className="text-gray-400 dark:text-gray-600">/</span>
|
||||
<button type="button" onClick={() => navigate("/agent-work-orders")} className="text-cyan-600 dark:text-cyan-400 hover:underline">
|
||||
{repoName}
|
||||
</button>
|
||||
<span className="text-gray-400 dark:text-gray-600">/</span>
|
||||
<span className="text-gray-900 dark:text-white">{workOrder.agent_work_order_id}</span>
|
||||
</div>
|
||||
|
||||
{/* Real-Time Execution Stats */}
|
||||
<RealTimeStats workOrderId={id} />
|
||||
|
||||
{/* Workflow Progress Bar */}
|
||||
<Card blur="md" transparency="light" edgePosition="top" edgeColor="cyan" size="lg" className="overflow-visible">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{repoName}</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
className="text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/10"
|
||||
aria-label={showDetails ? "Hide details" : "Show details"}
|
||||
>
|
||||
{showDetails ? (
|
||||
<ChevronUp className="w-4 h-4 mr-1" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 mr-1" aria-hidden="true" />
|
||||
)}
|
||||
Details
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Workflow Steps */}
|
||||
<div className="flex items-center justify-center gap-0">
|
||||
{stepHistory.steps.map((step, index) => (
|
||||
<div key={step.step} className="flex items-center">
|
||||
<WorkflowStepButton
|
||||
isCompleted={step.success}
|
||||
isActive={index === stepHistory.steps.length - 1 && !step.success}
|
||||
stepName={step.step}
|
||||
color="cyan"
|
||||
size={50}
|
||||
/>
|
||||
{/* Connecting Line - only show between steps */}
|
||||
{index < stepHistory.steps.length - 1 && (
|
||||
<div className="relative flex-shrink-0" style={{ width: "80px", height: "50px" }}>
|
||||
<div
|
||||
className={
|
||||
step.success
|
||||
? "absolute top-1/2 left-0 right-0 h-[2px] border-t-2 border-cyan-400 shadow-[0_0_8px_rgba(34,211,238,0.6)]"
|
||||
: "absolute top-1/2 left-0 right-0 h-[2px] border-t-2 border-gray-600 dark:border-gray-700"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Collapsible Details Section */}
|
||||
<AnimatePresence>
|
||||
{showDetails && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{
|
||||
height: {
|
||||
duration: 0.3,
|
||||
ease: [0.04, 0.62, 0.23, 0.98],
|
||||
},
|
||||
opacity: {
|
||||
duration: 0.2,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
}}
|
||||
style={{ overflow: "hidden" }}
|
||||
className="mt-6"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: -20 }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: -20 }}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-6 pt-6 border-t border-gray-200/50 dark:border-gray-700/30"
|
||||
>
|
||||
{/* Left Column - Details */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
|
||||
Details
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Status</p>
|
||||
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mt-0.5">
|
||||
{workOrder.status.charAt(0).toUpperCase() + workOrder.status.slice(1)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Sandbox Type</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white mt-0.5">{workOrder.sandbox_type}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Repository</p>
|
||||
<a
|
||||
href={workOrder.repository_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-cyan-600 dark:text-cyan-400 hover:underline inline-flex items-center gap-1 mt-0.5"
|
||||
>
|
||||
{workOrder.repository_url}
|
||||
<ExternalLink className="w-3 h-3" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
{workOrder.git_branch_name && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Branch</p>
|
||||
<p className="text-sm font-medium font-mono text-gray-900 dark:text-white mt-0.5">
|
||||
{workOrder.git_branch_name}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Work Order ID</p>
|
||||
<p className="text-sm font-medium font-mono text-gray-700 dark:text-gray-300 mt-0.5">
|
||||
{workOrder.agent_work_order_id}
|
||||
</p>
|
||||
</div>
|
||||
{workOrder.agent_session_id && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Session ID</p>
|
||||
<p className="text-sm font-medium font-mono text-gray-700 dark:text-gray-300 mt-0.5">
|
||||
{workOrder.agent_session_id}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{workOrder.github_pull_request_url && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Pull Request</p>
|
||||
<a
|
||||
href={workOrder.github_pull_request_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-cyan-600 dark:text-cyan-400 hover:underline inline-flex items-center gap-1 mt-0.5"
|
||||
>
|
||||
View PR
|
||||
<ExternalLink className="w-3 h-3" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{workOrder.github_issue_number && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">GitHub Issue</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white mt-0.5">
|
||||
#{workOrder.github_issue_number}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Statistics */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
|
||||
Statistics
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Commits</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-0.5">{workOrder.git_commit_count}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Files Changed</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-0.5">{workOrder.git_files_changed}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Steps Completed</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-0.5">
|
||||
{stepHistory.steps.filter((s) => s.success).length} / {stepHistory.steps.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Card>
|
||||
|
||||
{/* Step History */}
|
||||
<div className="space-y-4">
|
||||
{stepHistory.steps.map((step, index) => {
|
||||
const stepId = `${step.step}-${index}`;
|
||||
const isExpanded = expandedSteps.has(stepId);
|
||||
|
||||
return (
|
||||
<StepHistoryCard
|
||||
key={stepId}
|
||||
step={{
|
||||
id: stepId,
|
||||
stepName: step.step,
|
||||
timestamp: new Date(step.timestamp).toLocaleString(),
|
||||
output: step.output || "No output",
|
||||
session: step.session_id || "Unknown session",
|
||||
collapsible: true,
|
||||
isHumanInLoop: false,
|
||||
}}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={() => toggleStepExpansion(stepId)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +1,400 @@
|
||||
/**
|
||||
* AgentWorkOrdersView Component
|
||||
* Agent Work Orders View
|
||||
*
|
||||
* Main view for displaying and managing agent work orders.
|
||||
* Combines the work order list with create dialog.
|
||||
* Main view for agent work orders with repository management and layout switching.
|
||||
* Supports horizontal and sidebar layout modes.
|
||||
*/
|
||||
|
||||
import { ChevronLeft, ChevronRight, GitBranch, LayoutGrid, List, Plus, Search } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Button } from "@/features/ui/primitives/button";
|
||||
import { CreateWorkOrderDialog } from "../components/CreateWorkOrderDialog";
|
||||
import { WorkOrderList } from "../components/WorkOrderList";
|
||||
import { Input } from "@/features/ui/primitives/input";
|
||||
import { PillNavigation, type PillNavigationItem } from "@/features/ui/primitives/pill-navigation";
|
||||
import { cn } from "@/features/ui/primitives/styles";
|
||||
import { AddRepositoryModal } from "../components/AddRepositoryModal";
|
||||
import { CreateWorkOrderModal } from "../components/CreateWorkOrderModal";
|
||||
import { EditRepositoryModal } from "../components/EditRepositoryModal";
|
||||
import { RepositoryCard } from "../components/RepositoryCard";
|
||||
import { SidebarRepositoryCard } from "../components/SidebarRepositoryCard";
|
||||
import { WorkOrderTable } from "../components/WorkOrderTable";
|
||||
import { useStartWorkOrder, useWorkOrders } from "../hooks/useAgentWorkOrderQueries";
|
||||
import { useDeleteRepository, useRepositories } from "../hooks/useRepositoryQueries";
|
||||
import type { ConfiguredRepository } from "../types/repository";
|
||||
|
||||
/**
|
||||
* Layout mode type
|
||||
*/
|
||||
type LayoutMode = "horizontal" | "sidebar";
|
||||
|
||||
/**
|
||||
* Local storage key for layout preference
|
||||
*/
|
||||
const LAYOUT_MODE_KEY = "agent-work-orders-layout-mode";
|
||||
|
||||
/**
|
||||
* Get initial layout mode from localStorage
|
||||
*/
|
||||
function getInitialLayoutMode(): LayoutMode {
|
||||
const stored = localStorage.getItem(LAYOUT_MODE_KEY);
|
||||
return stored === "horizontal" || stored === "sidebar" ? stored : "sidebar";
|
||||
}
|
||||
|
||||
/**
|
||||
* Save layout mode to localStorage
|
||||
*/
|
||||
function saveLayoutMode(mode: LayoutMode): void {
|
||||
localStorage.setItem(LAYOUT_MODE_KEY, mode);
|
||||
}
|
||||
|
||||
export function AgentWorkOrdersView() {
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [layoutMode, setLayoutMode] = useState<LayoutMode>(getInitialLayoutMode);
|
||||
const [sidebarExpanded, setSidebarExpanded] = useState(true);
|
||||
const [showAddRepoModal, setShowAddRepoModal] = useState(false);
|
||||
const [showEditRepoModal, setShowEditRepoModal] = useState(false);
|
||||
const [editingRepository, setEditingRepository] = useState<ConfiguredRepository | null>(null);
|
||||
const [showNewWorkOrderModal, setShowNewWorkOrderModal] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const handleWorkOrderClick = (workOrderId: string) => {
|
||||
navigate(`/agent-work-orders/${workOrderId}`);
|
||||
// Get selected repository ID from URL query param
|
||||
const selectedRepositoryId = searchParams.get("repo") || undefined;
|
||||
|
||||
// Fetch data
|
||||
const { data: repositories = [], isLoading: isLoadingRepos } = useRepositories();
|
||||
const { data: workOrders = [], isLoading: isLoadingWorkOrders } = useWorkOrders();
|
||||
const startWorkOrder = useStartWorkOrder();
|
||||
const deleteRepository = useDeleteRepository();
|
||||
|
||||
/**
|
||||
* Update layout mode and persist preference
|
||||
*/
|
||||
const updateLayoutMode = (mode: LayoutMode) => {
|
||||
setLayoutMode(mode);
|
||||
saveLayoutMode(mode);
|
||||
};
|
||||
|
||||
const handleCreateSuccess = (workOrderId: string) => {
|
||||
navigate(`/agent-work-orders/${workOrderId}`);
|
||||
/**
|
||||
* Update selected repository in URL
|
||||
*/
|
||||
const selectRepository = (id: string | undefined) => {
|
||||
if (id) {
|
||||
setSearchParams({ repo: id });
|
||||
} else {
|
||||
setSearchParams({});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle opening edit modal for a repository
|
||||
*/
|
||||
const handleEditRepository = (repository: ConfiguredRepository) => {
|
||||
setEditingRepository(repository);
|
||||
setShowEditRepoModal(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle repository deletion
|
||||
*/
|
||||
const handleDeleteRepository = async (id: string) => {
|
||||
if (confirm("Are you sure you want to delete this repository configuration?")) {
|
||||
await deleteRepository.mutateAsync(id);
|
||||
// If this was the selected repository, clear selection
|
||||
if (selectedRepositoryId === id) {
|
||||
selectRepository(undefined);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate work order stats for a repository
|
||||
*/
|
||||
const getRepositoryStats = (repositoryId: string) => {
|
||||
const repoWorkOrders = workOrders.filter((wo) => {
|
||||
const repo = repositories.find((r) => r.id === repositoryId);
|
||||
return repo && wo.repository_url === repo.repository_url;
|
||||
});
|
||||
|
||||
return {
|
||||
total: repoWorkOrders.length,
|
||||
active: repoWorkOrders.filter((wo) => wo.status === "running" || wo.status === "pending").length,
|
||||
done: repoWorkOrders.filter((wo) => wo.status === "completed").length,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Build tab items for PillNavigation
|
||||
*/
|
||||
const tabItems: PillNavigationItem[] = [
|
||||
{ id: "all", label: "All Work Orders", icon: <GitBranch className="w-4 h-4" aria-hidden="true" /> },
|
||||
];
|
||||
|
||||
if (selectedRepositoryId) {
|
||||
const selectedRepo = repositories.find((r) => r.id === selectedRepositoryId);
|
||||
if (selectedRepo) {
|
||||
tabItems.push({
|
||||
id: selectedRepositoryId,
|
||||
label: selectedRepo.display_name || selectedRepo.repository_url,
|
||||
icon: <GitBranch className="w-4 h-4" aria-hidden="true" />,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Filter repositories by search query
|
||||
const filteredRepositories = repositories.filter((repo) => {
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
return (
|
||||
repo.display_name?.toLowerCase().includes(searchLower) ||
|
||||
repo.repository_url.toLowerCase().includes(searchLower) ||
|
||||
repo.owner?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Agent Work Orders</h1>
|
||||
<p className="text-gray-400">Create and monitor AI-driven development workflows</p>
|
||||
<div className="space-y-6">
|
||||
{/* Header Section */}
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
{/* Title */}
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Agent Work Orders</h1>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-gray-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search repositories..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
aria-label="Search repositories"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)}>Create Work Order</Button>
|
||||
|
||||
{/* Layout Toggle */}
|
||||
<div className="flex gap-1 p-1 bg-black/30 dark:bg-white/10 rounded-lg border border-white/10 dark:border-gray-700">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => updateLayoutMode("sidebar")}
|
||||
className={cn(
|
||||
"px-3",
|
||||
layoutMode === "sidebar" && "bg-purple-500/20 dark:bg-purple-500/30 text-purple-400 dark:text-purple-300",
|
||||
)}
|
||||
aria-label="Switch to sidebar layout"
|
||||
aria-pressed={layoutMode === "sidebar"}
|
||||
>
|
||||
<List className="w-4 h-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => updateLayoutMode("horizontal")}
|
||||
className={cn(
|
||||
"px-3",
|
||||
layoutMode === "horizontal" &&
|
||||
"bg-purple-500/20 dark:bg-purple-500/30 text-purple-400 dark:text-purple-300",
|
||||
)}
|
||||
aria-label="Switch to horizontal layout"
|
||||
aria-pressed={layoutMode === "horizontal"}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* New Repo Button */}
|
||||
<Button
|
||||
onClick={() => setShowAddRepoModal(true)}
|
||||
variant="cyan"
|
||||
aria-label="Add new repository"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" aria-hidden="true" />
|
||||
New Repo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<WorkOrderList onWorkOrderClick={handleWorkOrderClick} />
|
||||
|
||||
<CreateWorkOrderDialog
|
||||
open={isCreateDialogOpen}
|
||||
onClose={() => setIsCreateDialogOpen(false)}
|
||||
onSuccess={handleCreateSuccess}
|
||||
{/* Modals */}
|
||||
<AddRepositoryModal open={showAddRepoModal} onOpenChange={setShowAddRepoModal} />
|
||||
<EditRepositoryModal
|
||||
open={showEditRepoModal}
|
||||
onOpenChange={setShowEditRepoModal}
|
||||
repository={editingRepository}
|
||||
/>
|
||||
<CreateWorkOrderModal
|
||||
open={showNewWorkOrderModal}
|
||||
onOpenChange={setShowNewWorkOrderModal}
|
||||
selectedRepositoryId={selectedRepositoryId}
|
||||
/>
|
||||
|
||||
{/* Horizontal Layout */}
|
||||
{layoutMode === "horizontal" && (
|
||||
<>
|
||||
{/* Repository cards in horizontal scroll */}
|
||||
<div className="w-full max-w-full">
|
||||
<div className="overflow-x-auto overflow-y-visible py-8 -mx-6 px-6 scrollbar-hide">
|
||||
<div className="flex gap-4 min-w-max">
|
||||
{filteredRepositories.length === 0 ? (
|
||||
<div className="w-full text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{searchQuery ? "No repositories match your search" : "No repositories configured"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredRepositories.map((repository) => (
|
||||
<RepositoryCard
|
||||
key={repository.id}
|
||||
repository={repository}
|
||||
isSelected={selectedRepositoryId === repository.id}
|
||||
showAuroraGlow={selectedRepositoryId === repository.id}
|
||||
onSelect={() => selectRepository(repository.id)}
|
||||
onEdit={() => handleEditRepository(repository)}
|
||||
onDelete={() => handleDeleteRepository(repository.id)}
|
||||
stats={getRepositoryStats(repository.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PillNavigation centered */}
|
||||
<div className="flex items-center justify-center">
|
||||
<PillNavigation
|
||||
items={tabItems}
|
||||
activeSection={selectedRepositoryId || "all"}
|
||||
onSectionClick={(id) => {
|
||||
if (id === "all") {
|
||||
selectRepository(undefined);
|
||||
} else {
|
||||
selectRepository(id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Sidebar Layout */}
|
||||
{layoutMode === "sidebar" && (
|
||||
<div className="flex gap-4 min-w-0">
|
||||
{/* Collapsible Sidebar */}
|
||||
<div className={cn("shrink-0 transition-all duration-300 space-y-2", sidebarExpanded ? "w-56" : "w-12")}>
|
||||
{/* Collapse/Expand button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSidebarExpanded(!sidebarExpanded)}
|
||||
className="w-full justify-center"
|
||||
aria-label={sidebarExpanded ? "Collapse sidebar" : "Expand sidebar"}
|
||||
aria-expanded={sidebarExpanded}
|
||||
>
|
||||
{sidebarExpanded ? (
|
||||
<ChevronLeft className="w-4 h-4" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Sidebar content */}
|
||||
{sidebarExpanded && (
|
||||
<div className="space-y-2 px-1">
|
||||
{filteredRepositories.length === 0 ? (
|
||||
<div className="text-center py-8 px-2">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{searchQuery ? "No repositories match" : "No repositories"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredRepositories.map((repository) => (
|
||||
<SidebarRepositoryCard
|
||||
key={repository.id}
|
||||
repository={repository}
|
||||
isSelected={selectedRepositoryId === repository.id}
|
||||
isPinned={false}
|
||||
showAuroraGlow={selectedRepositoryId === repository.id}
|
||||
onSelect={() => selectRepository(repository.id)}
|
||||
onEdit={() => handleEditRepository(repository)}
|
||||
onDelete={() => handleDeleteRepository(repository.id)}
|
||||
stats={getRepositoryStats(repository.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
{/* PillNavigation centered */}
|
||||
<div className="flex items-center justify-center">
|
||||
<PillNavigation
|
||||
items={tabItems}
|
||||
activeSection={selectedRepositoryId || "all"}
|
||||
onSectionClick={(id) => {
|
||||
if (id === "all") {
|
||||
selectRepository(undefined);
|
||||
} else {
|
||||
selectRepository(id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Work Orders Table */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Work Orders</h3>
|
||||
<Button
|
||||
onClick={() => setShowNewWorkOrderModal(true)}
|
||||
variant="cyan"
|
||||
aria-label="Create new work order"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" aria-hidden="true" />
|
||||
New Work Order
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<WorkOrderTable
|
||||
workOrders={workOrders}
|
||||
selectedRepositoryId={selectedRepositoryId}
|
||||
onStartWorkOrder={(id) => startWorkOrder.mutate(id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Horizontal layout work orders table (below repository cards) */}
|
||||
{layoutMode === "horizontal" && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Work Orders</h3>
|
||||
<Button
|
||||
onClick={() => setShowNewWorkOrderModal(true)}
|
||||
variant="cyan"
|
||||
aria-label="Create new work order"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" aria-hidden="true" />
|
||||
New Work Order
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<WorkOrderTable
|
||||
workOrders={workOrders}
|
||||
selectedRepositoryId={selectedRepositoryId}
|
||||
onStartWorkOrder={(id) => startWorkOrder.mutate(id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{(isLoadingRepos || isLoadingWorkOrders) && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">Loading...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
/**
|
||||
* WorkOrderDetailView Component
|
||||
*
|
||||
* Detailed view of a single agent work order showing progress, step history,
|
||||
* and full metadata.
|
||||
*/
|
||||
|
||||
import { formatDistanceToNow, parseISO } from "date-fns";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Button } from "@/features/ui/primitives/button";
|
||||
import { StepHistoryTimeline } from "../components/StepHistoryTimeline";
|
||||
import { WorkOrderProgressBar } from "../components/WorkOrderProgressBar";
|
||||
import { RealTimeStats } from "../components/RealTimeStats";
|
||||
import { WorkOrderLogsPanel } from "../components/WorkOrderLogsPanel";
|
||||
import { useStepHistory, useWorkOrder } from "../hooks/useAgentWorkOrderQueries";
|
||||
|
||||
export function WorkOrderDetailView() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: workOrder, isLoading: isLoadingWorkOrder, isError: isErrorWorkOrder } = useWorkOrder(id);
|
||||
|
||||
const { data: stepHistory, isLoading: isLoadingSteps, isError: isErrorSteps } = useStepHistory(id);
|
||||
|
||||
if (isLoadingWorkOrder || isLoadingSteps) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 bg-gray-800 rounded w-1/3" />
|
||||
<div className="h-40 bg-gray-800 rounded" />
|
||||
<div className="h-60 bg-gray-800 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isErrorWorkOrder || isErrorSteps || !workOrder || !stepHistory) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-400 mb-4">Failed to load work order</p>
|
||||
<Button onClick={() => navigate("/agent-work-orders")}>Back to List</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Extract repository name from URL with fallback
|
||||
const repoName = workOrder.repository_url
|
||||
? workOrder.repository_url.split("/").slice(-2).join("/")
|
||||
: "Unknown Repository";
|
||||
|
||||
// Safely handle potentially invalid dates
|
||||
// Backend returns UTC timestamps without 'Z' suffix, so we add it to ensure correct parsing
|
||||
const timeAgo = workOrder.created_at
|
||||
? formatDistanceToNow(parseISO(workOrder.created_at.endsWith('Z') ? workOrder.created_at : `${workOrder.created_at}Z`), {
|
||||
addSuffix: true,
|
||||
})
|
||||
: "Unknown";
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-6">
|
||||
<Button variant="ghost" onClick={() => navigate("/agent-work-orders")} className="mb-4">
|
||||
← Back to List
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">{repoName}</h1>
|
||||
<p className="text-gray-400">Created {timeAgo}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Real-Time Stats Panel */}
|
||||
<RealTimeStats workOrderId={id} />
|
||||
|
||||
<div className="bg-gray-800 bg-opacity-50 backdrop-blur-sm border border-gray-700 rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Workflow Progress</h2>
|
||||
<WorkOrderProgressBar steps={stepHistory.steps} currentPhase={workOrder.current_phase} />
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 bg-opacity-50 backdrop-blur-sm border border-gray-700 rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Step History</h2>
|
||||
<StepHistoryTimeline steps={stepHistory.steps} currentPhase={workOrder.current_phase} />
|
||||
</div>
|
||||
|
||||
{/* Real-Time Logs Panel */}
|
||||
<WorkOrderLogsPanel workOrderId={id} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="bg-gray-800 bg-opacity-50 backdrop-blur-sm border border-gray-700 rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Details</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Status</p>
|
||||
<p
|
||||
className={`text-lg font-semibold ${
|
||||
workOrder.status === "completed"
|
||||
? "text-green-400"
|
||||
: workOrder.status === "failed"
|
||||
? "text-red-400"
|
||||
: workOrder.status === "running"
|
||||
? "text-blue-400"
|
||||
: "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{workOrder.status.charAt(0).toUpperCase() + workOrder.status.slice(1)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Sandbox Type</p>
|
||||
<p className="text-white">{workOrder.sandbox_type}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Repository</p>
|
||||
<a
|
||||
href={workOrder.repository_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 hover:text-blue-300 underline break-all"
|
||||
>
|
||||
{workOrder.repository_url}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{workOrder.git_branch_name && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Branch</p>
|
||||
<p className="text-white font-mono text-sm">{workOrder.git_branch_name}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workOrder.github_pull_request_url && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Pull Request</p>
|
||||
<a
|
||||
href={workOrder.github_pull_request_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 hover:text-blue-300 underline break-all"
|
||||
>
|
||||
View PR
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workOrder.github_issue_number && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">GitHub Issue</p>
|
||||
<p className="text-white">#{workOrder.github_issue_number}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Work Order ID</p>
|
||||
<p className="text-white font-mono text-xs break-all">{workOrder.agent_work_order_id}</p>
|
||||
</div>
|
||||
|
||||
{workOrder.agent_session_id && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Session ID</p>
|
||||
<p className="text-white font-mono text-xs break-all">{workOrder.agent_session_id}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{workOrder.error_message && (
|
||||
<div className="bg-red-900 bg-opacity-30 border border-red-700 rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold text-red-300 mb-4">Error</h2>
|
||||
<p className="text-sm text-red-300 font-mono whitespace-pre-wrap">{workOrder.error_message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-gray-800 bg-opacity-50 backdrop-blur-sm border border-gray-700 rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Statistics</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Commits</p>
|
||||
<p className="text-white text-lg font-semibold">{workOrder.git_commit_count}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Files Changed</p>
|
||||
<p className="text-white text-lg font-semibold">{workOrder.git_files_changed}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Steps Completed</p>
|
||||
<p className="text-white text-lg font-semibold">
|
||||
{stepHistory.steps.filter((s) => s.success).length} / {stepHistory.steps.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -268,9 +268,7 @@ export const CrawlingProgress: React.FC<CrawlingProgressProps> = ({ onSwitchToBr
|
||||
{operation.discovered_file}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 truncate block">
|
||||
{operation.discovered_file}
|
||||
</span>
|
||||
<span className="text-sm text-gray-400 truncate block">{operation.discovered_file}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -283,7 +281,7 @@ export const CrawlingProgress: React.FC<CrawlingProgressProps> = ({ onSwitchToBr
|
||||
{operation.linked_files.length > 1 ? "s" : ""}
|
||||
</div>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{operation.linked_files.map((file: string, idx: number) => (
|
||||
{operation.linked_files.map((file: string, idx: number) =>
|
||||
isValidHttpUrl(file) ? (
|
||||
<a
|
||||
key={idx}
|
||||
@@ -298,8 +296,8 @@ export const CrawlingProgress: React.FC<CrawlingProgressProps> = ({ onSwitchToBr
|
||||
<span key={idx} className="text-xs text-gray-400 truncate block">
|
||||
• {file}
|
||||
</span>
|
||||
)
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -13,32 +13,32 @@ const SAFE_PROTOCOLS = ["http:", "https:"];
|
||||
* @returns true if URL is safe (http/https), false otherwise
|
||||
*/
|
||||
export function isValidHttpUrl(url: string | undefined | null): boolean {
|
||||
if (!url || typeof url !== "string") {
|
||||
return false;
|
||||
}
|
||||
if (!url || typeof url !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Trim whitespace
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
// Trim whitespace
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
|
||||
// Only allow http and https protocols
|
||||
if (!SAFE_PROTOCOLS.includes(parsed.protocol)) {
|
||||
return false;
|
||||
}
|
||||
// Only allow http and https protocols
|
||||
if (!SAFE_PROTOCOLS.includes(parsed.protocol)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Basic hostname validation (must have at least one dot or be localhost)
|
||||
if (!parsed.hostname.includes(".") && parsed.hostname !== "localhost") {
|
||||
return false;
|
||||
}
|
||||
// Basic hostname validation (must have at least one dot or be localhost)
|
||||
if (!parsed.hostname.includes(".") && parsed.hostname !== "localhost") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
// URL parsing failed - not a valid URL
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
// URL parsing failed - not a valid URL
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { motion } from "framer-motion";
|
||||
import type React from "react";
|
||||
import { cn } from "@/features/ui/primitives/styles";
|
||||
|
||||
interface WorkflowStepButtonProps {
|
||||
isCompleted: boolean;
|
||||
@@ -31,31 +32,31 @@ export const WorkflowStepButton: React.FC<WorkflowStepButtonProps> = ({
|
||||
}) => {
|
||||
const colorMap = {
|
||||
purple: {
|
||||
border: "border-purple-400",
|
||||
border: "border-purple-400 dark:border-purple-300",
|
||||
glow: "shadow-[0_0_15px_rgba(168,85,247,0.8)]",
|
||||
glowHover: "hover:shadow-[0_0_25px_rgba(168,85,247,1)]",
|
||||
fill: "bg-purple-400",
|
||||
fill: "bg-purple-400 dark:bg-purple-300",
|
||||
innerGlow: "shadow-[inset_0_0_10px_rgba(168,85,247,0.8)]",
|
||||
},
|
||||
green: {
|
||||
border: "border-green-400",
|
||||
border: "border-green-400 dark:border-green-300",
|
||||
glow: "shadow-[0_0_15px_rgba(34,197,94,0.8)]",
|
||||
glowHover: "hover:shadow-[0_0_25px_rgba(34,197,94,1)]",
|
||||
fill: "bg-green-400",
|
||||
fill: "bg-green-400 dark:bg-green-300",
|
||||
innerGlow: "shadow-[inset_0_0_10px_rgba(34,197,94,0.8)]",
|
||||
},
|
||||
blue: {
|
||||
border: "border-blue-400",
|
||||
border: "border-blue-400 dark:border-blue-300",
|
||||
glow: "shadow-[0_0_15px_rgba(59,130,246,0.8)]",
|
||||
glowHover: "hover:shadow-[0_0_25px_rgba(59,130,246,1)]",
|
||||
fill: "bg-blue-400",
|
||||
fill: "bg-blue-400 dark:bg-blue-300",
|
||||
innerGlow: "shadow-[inset_0_0_10px_rgba(59,130,246,0.8)]",
|
||||
},
|
||||
cyan: {
|
||||
border: "border-cyan-400",
|
||||
border: "border-cyan-400 dark:border-cyan-300",
|
||||
glow: "shadow-[0_0_15px_rgba(34,211,238,0.8)]",
|
||||
glowHover: "hover:shadow-[0_0_25px_rgba(34,211,238,1)]",
|
||||
fill: "bg-cyan-400",
|
||||
fill: "bg-cyan-400 dark:bg-cyan-300",
|
||||
innerGlow: "shadow-[inset_0_0_10px_rgba(34,211,238,0.8)]",
|
||||
},
|
||||
};
|
||||
@@ -66,15 +67,14 @@ export const WorkflowStepButton: React.FC<WorkflowStepButtonProps> = ({
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<motion.button
|
||||
onClick={onClick}
|
||||
className={`
|
||||
relative rounded-full border-2 transition-all duration-300
|
||||
${styles.border}
|
||||
${isCompleted ? styles.glow : "shadow-[0_0_5px_rgba(0,0,0,0.3)]"}
|
||||
${styles.glowHover}
|
||||
bg-gradient-to-b from-gray-900 to-black
|
||||
hover:scale-110
|
||||
active:scale-95
|
||||
`}
|
||||
className={cn(
|
||||
"relative rounded-full border-2 transition-all duration-300",
|
||||
styles.border,
|
||||
isCompleted ? styles.glow : "shadow-[0_0_5px_rgba(0,0,0,0.3)]",
|
||||
styles.glowHover,
|
||||
"bg-gradient-to-b from-gray-900 to-black dark:from-gray-800 dark:to-gray-900",
|
||||
"hover:scale-110 active:scale-95",
|
||||
)}
|
||||
style={{ width: size, height: size }}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
@@ -83,11 +83,10 @@ export const WorkflowStepButton: React.FC<WorkflowStepButtonProps> = ({
|
||||
>
|
||||
{/* Outer ring glow effect */}
|
||||
<motion.div
|
||||
className={`
|
||||
absolute inset-[-4px] rounded-full border-2
|
||||
${isCompleted ? styles.border : "border-transparent"}
|
||||
blur-sm
|
||||
`}
|
||||
className={cn(
|
||||
"absolute inset-[-4px] rounded-full border-2 blur-sm",
|
||||
isCompleted ? styles.border : "border-transparent",
|
||||
)}
|
||||
animate={{
|
||||
opacity: isCompleted ? [0.3, 0.6, 0.3] : 0,
|
||||
}}
|
||||
@@ -100,11 +99,7 @@ export const WorkflowStepButton: React.FC<WorkflowStepButtonProps> = ({
|
||||
|
||||
{/* Inner glow effect */}
|
||||
<motion.div
|
||||
className={`
|
||||
absolute inset-[2px] rounded-full
|
||||
${isCompleted ? styles.fill : ""}
|
||||
blur-md opacity-20
|
||||
`}
|
||||
className={cn("absolute inset-[2px] rounded-full blur-md opacity-20", isCompleted && styles.fill)}
|
||||
animate={{
|
||||
opacity: isCompleted ? [0.1, 0.3, 0.1] : 0,
|
||||
}}
|
||||
@@ -155,13 +150,14 @@ export const WorkflowStepButton: React.FC<WorkflowStepButtonProps> = ({
|
||||
|
||||
{/* Step name label */}
|
||||
<span
|
||||
className={`text-xs font-medium transition-colors ${
|
||||
className={cn(
|
||||
"text-xs font-medium transition-colors",
|
||||
isCompleted
|
||||
? "text-cyan-400 dark:text-cyan-300"
|
||||
: isActive
|
||||
? "text-blue-500 dark:text-blue-400"
|
||||
: "text-gray-500 dark:text-gray-400"
|
||||
}`}
|
||||
: "text-gray-500 dark:text-gray-400",
|
||||
)}
|
||||
>
|
||||
{stepName}
|
||||
</span>
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
/**
|
||||
* AgentWorkOrderDetailPage Component
|
||||
* Agent Work Order 2 Detail Page
|
||||
*
|
||||
* Route wrapper for the agent work order detail view.
|
||||
* Delegates to WorkOrderDetailView for actual implementation.
|
||||
* Page wrapper for the redesigned agent work order detail view.
|
||||
* Routes to this page from /agent-work-orders2/:id
|
||||
*/
|
||||
|
||||
import { WorkOrderDetailView } from "@/features/agent-work-orders/views/WorkOrderDetailView";
|
||||
import { AgentWorkOrderDetailView } from "../features/agent-work-orders/views/AgentWorkOrderDetailView";
|
||||
|
||||
function AgentWorkOrderDetailPage() {
|
||||
return <WorkOrderDetailView />;
|
||||
export function AgentWorkOrderDetailPage() {
|
||||
return <AgentWorkOrderDetailView />;
|
||||
}
|
||||
|
||||
export { AgentWorkOrderDetailPage };
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
/**
|
||||
* AgentWorkOrdersPage Component
|
||||
* Agent Work Orders 2 Page
|
||||
*
|
||||
* Route wrapper for the agent work orders feature.
|
||||
* Delegates to AgentWorkOrdersView for actual implementation.
|
||||
* Page wrapper for the redesigned agent work orders interface.
|
||||
* Routes to this page from /agent-work-orders2
|
||||
*/
|
||||
|
||||
import { AgentWorkOrdersView } from "@/features/agent-work-orders/views/AgentWorkOrdersView";
|
||||
import { AgentWorkOrdersView } from "../features/agent-work-orders/views/AgentWorkOrdersView";
|
||||
|
||||
function AgentWorkOrdersPage() {
|
||||
export function AgentWorkOrdersPage() {
|
||||
return <AgentWorkOrdersView />;
|
||||
}
|
||||
|
||||
export { AgentWorkOrdersPage };
|
||||
|
||||
Reference in New Issue
Block a user