Refactor the UI is working, work in progress. Zustand next to work better with SSE.

This commit is contained in:
sean-eskerium
2025-10-25 21:50:12 -04:00
parent 4025f88ee9
commit 28aa3ac76d
42 changed files with 4992 additions and 1330 deletions

View File

@@ -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>
);
};

View File

@@ -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",

View File

@@ -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">

View File

@@ -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
};

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
)}
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -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();
});
});

View File

@@ -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");
});
});

View File

@@ -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");
});
});

View File

@@ -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);
});
});
});

View File

@@ -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));
});
},
});
}

View File

@@ -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",

View File

@@ -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");
}
},
});
}

View File

@@ -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");
});
});
});

View File

@@ -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",
});
},
};

View File

@@ -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;

View File

@@ -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";

View File

@@ -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[];
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
)}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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 };

View File

@@ -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 };