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