6 Commits

Author SHA1 Message Date
Felipe Cardoso
f0b04d53af test(frontend): update tests for type changes
Update all test files to use correct enum values:
- AgentPanel, AgentStatusIndicator tests
- ProjectHeader, StatusBadge tests
- IssueSummary, IssueTable tests
- StatusBadge, StatusWorkflow tests (issues)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 12:48:11 +01:00
Felipe Cardoso
35af7daf90 fix(frontend): align project types with backend enums
- Fix ProjectStatus: use 'active' instead of 'in_progress'
- Fix AgentStatus: remove 'active'/'pending'/'error', add 'waiting'
- Fix SprintStatus: add 'in_review'
- Rename IssueSummary to IssueCountSummary
- Update all components to use correct enum values

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 12:48:02 +01:00
Felipe Cardoso
5fab15a11e fix(frontend): align issue types with backend enums
- Fix IssueStatus: remove 'done', keep 'closed'
- Add IssuePriority 'critical' level
- Add IssueType enum (epic, story, task, bug)
- Update constants, hooks, and mocks to match
- Fix StatusWorkflow component icons

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 12:47:52 +01:00
Felipe Cardoso
ab913575e1 feat(frontend): add ErrorBoundary component
Add React ErrorBoundary component for catching and handling
render errors in component trees with fallback UI.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 12:47:38 +01:00
Felipe Cardoso
82cb6386a6 fix(backend): regenerate Syndarix migration to match models
Completely rewrote migration 0004 to match current model definitions:
- Added issue_type ENUM (epic, story, task, bug)
- Fixed sprint_status ENUM to include in_review
- Fixed all table columns to match models exactly
- Fixed all indexes and constraints

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 12:47:30 +01:00
Felipe Cardoso
2d05035c1d fix(backend): add unique constraint for sprint numbers
Add UniqueConstraint to Sprint model to ensure sprint numbers
are unique within a project, matching the migration specification.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 12:47:19 +01:00
24 changed files with 436 additions and 175 deletions

View File

@@ -2,14 +2,14 @@
Revision ID: 0004
Revises: 0003
Create Date: 2025-12-30
Create Date: 2025-12-31
This migration creates the core Syndarix domain tables:
- projects: Client engagement projects
- agent_types: Agent template configurations
- agent_instances: Spawned agent instances assigned to projects
- issues: Work items (stories, tasks, bugs)
- sprints: Sprint containers for issues
- issues: Work items (epics, stories, tasks, bugs)
"""
from collections.abc import Sequence
@@ -28,7 +28,9 @@ depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Create Syndarix domain tables."""
# Create ENUM types first
# =========================================================================
# Create ENUM types
# =========================================================================
op.execute(
"""
CREATE TYPE autonomy_level AS ENUM (
@@ -64,24 +66,24 @@ def upgrade() -> None:
)
"""
)
op.execute(
"""
CREATE TYPE issue_type AS ENUM (
'epic', 'story', 'task', 'bug'
)
"""
)
op.execute(
"""
CREATE TYPE issue_status AS ENUM (
'open', 'in_progress', 'in_review', 'closed', 'blocked'
'open', 'in_progress', 'in_review', 'blocked', 'closed'
)
"""
)
op.execute(
"""
CREATE TYPE issue_priority AS ENUM (
'critical', 'high', 'medium', 'low'
)
"""
)
op.execute(
"""
CREATE TYPE external_tracker_type AS ENUM (
'gitea', 'github', 'gitlab', 'jira'
'low', 'medium', 'high', 'critical'
)
"""
)
@@ -95,12 +97,14 @@ def upgrade() -> None:
op.execute(
"""
CREATE TYPE sprint_status AS ENUM (
'planned', 'active', 'completed', 'cancelled'
'planned', 'active', 'in_review', 'completed', 'cancelled'
)
"""
)
# =========================================================================
# Create projects table
# =========================================================================
op.create_table(
"projects",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
@@ -152,7 +156,10 @@ def upgrade() -> None:
server_default="auto",
),
sa.Column(
"settings", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="{}"
"settings",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default="{}",
),
sa.Column("owner_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column(
@@ -168,11 +175,10 @@ def upgrade() -> None:
server_default=sa.text("now()"),
),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(
["owner_id"], ["users.id"], ondelete="SET NULL"
),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="SET NULL"),
sa.UniqueConstraint("slug"),
)
# Single column indexes
op.create_index("ix_projects_name", "projects", ["name"])
op.create_index("ix_projects_slug", "projects", ["slug"])
op.create_index("ix_projects_status", "projects", ["status"])
@@ -180,6 +186,7 @@ def upgrade() -> None:
op.create_index("ix_projects_complexity", "projects", ["complexity"])
op.create_index("ix_projects_client_mode", "projects", ["client_mode"])
op.create_index("ix_projects_owner_id", "projects", ["owner_id"])
# Composite indexes
op.create_index("ix_projects_slug_status", "projects", ["slug", "status"])
op.create_index("ix_projects_owner_status", "projects", ["owner_id", "status"])
op.create_index(
@@ -189,13 +196,25 @@ def upgrade() -> None:
"ix_projects_complexity_status", "projects", ["complexity", "status"]
)
# =========================================================================
# Create agent_types table
# =========================================================================
op.create_table(
"agent_types",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("slug", sa.String(100), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("slug", sa.String(255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
# Areas of expertise (e.g., ["python", "fastapi", "databases"])
sa.Column(
"expertise",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default="[]",
),
# System prompt defining personality and behavior (required)
sa.Column("personality_prompt", sa.Text(), nullable=False),
# LLM model configuration
sa.Column("primary_model", sa.String(100), nullable=False),
sa.Column(
"fallback_models",
@@ -203,16 +222,23 @@ def upgrade() -> None:
nullable=False,
server_default="[]",
),
sa.Column("system_prompt", sa.Text(), nullable=True),
sa.Column("personality_prompt", sa.Text(), nullable=True),
# Model parameters (temperature, max_tokens, etc.)
sa.Column(
"capabilities",
"model_params",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default="{}",
),
# MCP servers this agent can connect to
sa.Column(
"mcp_servers",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default="[]",
),
# Tool permissions configuration
sa.Column(
"default_config",
"tool_permissions",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default="{}",
@@ -233,12 +259,17 @@ def upgrade() -> None:
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("slug"),
)
# Single column indexes
op.create_index("ix_agent_types_name", "agent_types", ["name"])
op.create_index("ix_agent_types_slug", "agent_types", ["slug"])
op.create_index("ix_agent_types_is_active", "agent_types", ["is_active"])
op.create_index("ix_agent_types_primary_model", "agent_types", ["primary_model"])
# Composite indexes
op.create_index("ix_agent_types_slug_active", "agent_types", ["slug", "is_active"])
op.create_index("ix_agent_types_name_active", "agent_types", ["name", "is_active"])
# =========================================================================
# Create agent_instances table
# =========================================================================
op.create_table(
"agent_instances",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
@@ -260,17 +291,28 @@ def upgrade() -> None:
server_default="idle",
),
sa.Column("current_task", sa.Text(), nullable=True),
# Short-term memory (conversation context, recent decisions)
sa.Column(
"config_overrides",
"short_term_memory",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default="{}",
),
# Reference to long-term memory in vector store
sa.Column("long_term_memory_ref", sa.String(500), nullable=True),
# Session ID for active MCP connections
sa.Column("session_id", sa.String(255), nullable=True),
# Activity tracking
sa.Column("last_activity_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("terminated_at", sa.DateTime(timezone=True), nullable=True),
# Usage metrics
sa.Column("tasks_completed", sa.Integer(), nullable=False, server_default="0"),
sa.Column("tokens_used", sa.BigInteger(), nullable=False, server_default="0"),
sa.Column(
"metadata",
postgresql.JSONB(astext_type=sa.Text()),
"cost_incurred",
sa.Numeric(precision=10, scale=4),
nullable=False,
server_default="{}",
server_default="0",
),
sa.Column(
"created_at",
@@ -290,12 +332,21 @@ def upgrade() -> None:
),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
)
# Single column indexes
op.create_index("ix_agent_instances_name", "agent_instances", ["name"])
op.create_index("ix_agent_instances_status", "agent_instances", ["status"])
op.create_index(
"ix_agent_instances_agent_type_id", "agent_instances", ["agent_type_id"]
)
op.create_index("ix_agent_instances_project_id", "agent_instances", ["project_id"])
op.create_index("ix_agent_instances_session_id", "agent_instances", ["session_id"])
op.create_index(
"ix_agent_instances_last_activity_at", "agent_instances", ["last_activity_at"]
)
op.create_index(
"ix_agent_instances_terminated_at", "agent_instances", ["terminated_at"]
)
# Composite indexes
op.create_index(
"ix_agent_instances_project_status",
"agent_instances",
@@ -306,22 +357,30 @@ def upgrade() -> None:
"agent_instances",
["agent_type_id", "status"],
)
op.create_index(
"ix_agent_instances_project_type",
"agent_instances",
["project_id", "agent_type_id"],
)
# =========================================================================
# Create sprints table (before issues for FK reference)
# =========================================================================
op.create_table(
"sprints",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("number", sa.Integer(), nullable=False),
sa.Column("goal", sa.Text(), nullable=True),
sa.Column("start_date", sa.Date(), nullable=True),
sa.Column("end_date", sa.Date(), nullable=True),
sa.Column("start_date", sa.Date(), nullable=False),
sa.Column("end_date", sa.Date(), nullable=False),
sa.Column(
"status",
sa.Enum(
"planned",
"active",
"in_review",
"completed",
"cancelled",
name="sprint_status",
@@ -348,29 +407,53 @@ def upgrade() -> None:
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.UniqueConstraint("project_id", "number", name="uq_sprint_project_number"),
)
op.create_index("ix_sprints_name", "sprints", ["name"])
op.create_index("ix_sprints_number", "sprints", ["number"])
op.create_index("ix_sprints_status", "sprints", ["status"])
# Single column indexes
op.create_index("ix_sprints_project_id", "sprints", ["project_id"])
op.create_index("ix_sprints_status", "sprints", ["status"])
op.create_index("ix_sprints_start_date", "sprints", ["start_date"])
op.create_index("ix_sprints_end_date", "sprints", ["end_date"])
# Composite indexes
op.create_index("ix_sprints_project_status", "sprints", ["project_id", "status"])
op.create_index("ix_sprints_project_number", "sprints", ["project_id", "number"])
op.create_index("ix_sprints_date_range", "sprints", ["start_date", "end_date"])
# =========================================================================
# Create issues table
# =========================================================================
op.create_table(
"issues",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("sprint_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("assigned_agent_id", postgresql.UUID(as_uuid=True), nullable=True),
# Parent issue for hierarchy (Epic -> Story -> Task)
sa.Column("parent_id", postgresql.UUID(as_uuid=True), nullable=True),
# Issue type (epic, story, task, bug)
sa.Column(
"type",
sa.Enum(
"epic",
"story",
"task",
"bug",
name="issue_type",
create_type=False,
),
nullable=False,
server_default="task",
),
# Reporter (who created this issue)
sa.Column("reporter_id", postgresql.UUID(as_uuid=True), nullable=True),
# Issue content
sa.Column("title", sa.String(500), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("body", sa.Text(), nullable=False, server_default=""),
# Status and priority
sa.Column(
"status",
sa.Enum(
"open",
"in_progress",
"in_review",
"closed",
"blocked",
"closed",
name="issue_status",
create_type=False,
),
@@ -380,33 +463,37 @@ def upgrade() -> None:
sa.Column(
"priority",
sa.Enum(
"critical", "high", "medium", "low", name="issue_priority", create_type=False
"low",
"medium",
"high",
"critical",
name="issue_priority",
create_type=False,
),
nullable=False,
server_default="medium",
),
sa.Column("story_points", sa.Integer(), nullable=True),
# Labels for categorization
sa.Column(
"labels",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default="[]",
),
sa.Column(
"external_tracker",
sa.Enum(
"gitea",
"github",
"gitlab",
"jira",
name="external_tracker_type",
create_type=False,
),
nullable=True,
),
sa.Column("external_id", sa.String(255), nullable=True),
sa.Column("external_url", sa.String(2048), nullable=True),
sa.Column("external_number", sa.Integer(), nullable=True),
# Assignment - agent or human (mutually exclusive)
sa.Column("assigned_agent_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("human_assignee", sa.String(255), nullable=True),
# Sprint association
sa.Column("sprint_id", postgresql.UUID(as_uuid=True), nullable=True),
# Estimation
sa.Column("story_points", sa.Integer(), nullable=True),
sa.Column("due_date", sa.Date(), nullable=True),
# External tracker integration (String for flexibility)
sa.Column("external_tracker_type", sa.String(50), nullable=True),
sa.Column("external_issue_id", sa.String(255), nullable=True),
sa.Column("remote_url", sa.String(1000), nullable=True),
sa.Column("external_issue_number", sa.Integer(), nullable=True),
# Sync status
sa.Column(
"sync_status",
sa.Enum(
@@ -417,15 +504,13 @@ def upgrade() -> None:
name="sync_status",
create_type=False,
),
nullable=True,
nullable=False,
server_default="synced",
),
sa.Column("last_synced_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"metadata",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default="{}",
),
sa.Column("external_updated_at", sa.DateTime(timezone=True), nullable=True),
# Lifecycle
sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
@@ -440,29 +525,43 @@ def upgrade() -> None:
),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(
["parent_id"], ["issues.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(["sprint_id"], ["sprints.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(
["assigned_agent_id"], ["agent_instances.id"], ondelete="SET NULL"
),
)
op.create_index("ix_issues_title", "issues", ["title"])
# Single column indexes
op.create_index("ix_issues_project_id", "issues", ["project_id"])
op.create_index("ix_issues_parent_id", "issues", ["parent_id"])
op.create_index("ix_issues_type", "issues", ["type"])
op.create_index("ix_issues_reporter_id", "issues", ["reporter_id"])
op.create_index("ix_issues_status", "issues", ["status"])
op.create_index("ix_issues_priority", "issues", ["priority"])
op.create_index("ix_issues_project_id", "issues", ["project_id"])
op.create_index("ix_issues_sprint_id", "issues", ["sprint_id"])
op.create_index("ix_issues_assigned_agent_id", "issues", ["assigned_agent_id"])
op.create_index("ix_issues_external_tracker", "issues", ["external_tracker"])
op.create_index("ix_issues_human_assignee", "issues", ["human_assignee"])
op.create_index("ix_issues_sprint_id", "issues", ["sprint_id"])
op.create_index("ix_issues_due_date", "issues", ["due_date"])
op.create_index("ix_issues_external_tracker_type", "issues", ["external_tracker_type"])
op.create_index("ix_issues_sync_status", "issues", ["sync_status"])
op.create_index("ix_issues_closed_at", "issues", ["closed_at"])
# Composite indexes
op.create_index("ix_issues_project_status", "issues", ["project_id", "status"])
op.create_index("ix_issues_project_priority", "issues", ["project_id", "priority"])
op.create_index("ix_issues_project_sprint", "issues", ["project_id", "sprint_id"])
op.create_index("ix_issues_project_type", "issues", ["project_id", "type"])
op.create_index("ix_issues_project_agent", "issues", ["project_id", "assigned_agent_id"])
op.create_index(
"ix_issues_project_status_priority",
"issues",
["project_id", "status", "priority"],
)
op.create_index(
"ix_issues_external",
"ix_issues_external_tracker_id",
"issues",
["project_id", "external_tracker", "external_id"],
["external_tracker_type", "external_issue_id"],
)
@@ -478,9 +577,9 @@ def downgrade() -> None:
# Drop ENUM types
op.execute("DROP TYPE IF EXISTS sprint_status")
op.execute("DROP TYPE IF EXISTS sync_status")
op.execute("DROP TYPE IF EXISTS external_tracker_type")
op.execute("DROP TYPE IF EXISTS issue_priority")
op.execute("DROP TYPE IF EXISTS issue_status")
op.execute("DROP TYPE IF EXISTS issue_type")
op.execute("DROP TYPE IF EXISTS agent_status")
op.execute("DROP TYPE IF EXISTS client_mode")
op.execute("DROP TYPE IF EXISTS project_complexity")

View File

@@ -5,7 +5,7 @@ Sprint model for Syndarix AI consulting platform.
A Sprint represents a time-boxed iteration for organizing and delivering work.
"""
from sqlalchemy import Column, Date, Enum, ForeignKey, Index, Integer, String, Text
from sqlalchemy import Column, Date, Enum, ForeignKey, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import relationship
@@ -65,6 +65,8 @@ class Sprint(Base, UUIDMixin, TimestampMixin):
Index("ix_sprints_project_status", "project_id", "status"),
Index("ix_sprints_project_number", "project_id", "number"),
Index("ix_sprints_date_range", "start_date", "end_date"),
# Ensure sprint numbers are unique within a project
UniqueConstraint("project_id", "number", name="uq_sprint_project_number"),
)
def __repr__(self) -> str:

View File

@@ -0,0 +1,158 @@
/**
* Error Boundary Component
*
* Catches JavaScript errors in child component tree and displays fallback UI.
* Prevents the entire app from crashing due to component-level errors.
*
* @see https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary
*/
'use client';
import { Component, type ReactNode } from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
// ============================================================================
// Types
// ============================================================================
interface ErrorBoundaryProps {
/** Child components to render */
children: ReactNode;
/** Optional fallback component to render on error */
fallback?: ReactNode;
/** Optional callback when error occurs */
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
/** Whether to show reset button (default: true) */
showReset?: boolean;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
// ============================================================================
// Default Fallback Component
// ============================================================================
interface DefaultFallbackProps {
error: Error | null;
onReset: () => void;
showReset: boolean;
}
function DefaultFallback({ error, onReset, showReset }: DefaultFallbackProps) {
return (
<Card className="m-4 border-destructive/50 bg-destructive/5">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-5 w-5" aria-hidden="true" />
Something went wrong
</CardTitle>
<CardDescription>
An unexpected error occurred. Please try again or contact support if
the problem persists.
</CardDescription>
</CardHeader>
<CardContent>
{error && (
<div className="mb-4 rounded-md bg-muted p-3">
<p className="font-mono text-sm text-muted-foreground">
{error.message}
</p>
</div>
)}
{showReset && (
<Button
variant="outline"
size="sm"
onClick={onReset}
className="gap-2"
>
<RefreshCw className="h-4 w-4" aria-hidden="true" />
Try again
</Button>
)}
</CardContent>
</Card>
);
}
// ============================================================================
// Error Boundary Component
// ============================================================================
/**
* Error boundary for catching and handling render errors in React components.
*
* @example
* ```tsx
* <ErrorBoundary onError={(error) => logError(error)}>
* <MyComponent />
* </ErrorBoundary>
* ```
*
* @example With custom fallback
* ```tsx
* <ErrorBoundary fallback={<CustomErrorUI />}>
* <MyComponent />
* </ErrorBoundary>
* ```
*/
export class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
// Log error to console in development
console.error('ErrorBoundary caught an error:', error, errorInfo);
// Call optional error callback
this.props.onError?.(error, errorInfo);
}
handleReset = (): void => {
this.setState({ hasError: false, error: null });
};
render(): ReactNode {
const { hasError, error } = this.state;
const { children, fallback, showReset = true } = this.props;
if (hasError) {
if (fallback) {
return fallback;
}
return (
<DefaultFallback
error={error}
onReset={this.handleReset}
showReset={showReset}
/>
);
}
return children;
}
}
export default ErrorBoundary;

View File

@@ -1,4 +1,4 @@
// Common reusable components
// Examples: LoadingSpinner, ErrorBoundary, ConfirmDialog, etc.
export {};
export { ErrorBoundary } from './ErrorBoundary';

View File

@@ -126,7 +126,7 @@ function AgentListItem({
<DropdownMenuItem onClick={() => onAction(agent.id, 'view')}>
View Details
</DropdownMenuItem>
{agent.status === 'active' || agent.status === 'working' ? (
{agent.status === 'working' || agent.status === 'waiting' ? (
<DropdownMenuItem onClick={() => onAction(agent.id, 'pause')}>
Pause Agent
</DropdownMenuItem>
@@ -196,7 +196,7 @@ export function AgentPanel({
}
const activeAgentCount = agents.filter(
(a) => a.status === 'active' || a.status === 'working'
(a) => a.status === 'working' || a.status === 'waiting'
).length;
return (

View File

@@ -14,21 +14,17 @@ const statusConfig: Record<AgentStatus, { color: string; label: string }> = {
color: 'bg-yellow-500',
label: 'Idle',
},
active: {
color: 'bg-green-500',
label: 'Active',
},
working: {
color: 'bg-green-500 animate-pulse',
label: 'Working',
},
pending: {
color: 'bg-gray-400',
label: 'Pending',
waiting: {
color: 'bg-blue-500',
label: 'Waiting',
},
error: {
color: 'bg-red-500',
label: 'Error',
paused: {
color: 'bg-gray-400',
label: 'Paused',
},
terminated: {
color: 'bg-gray-600',
@@ -49,7 +45,7 @@ export function AgentStatusIndicator({
showLabel = false,
className,
}: AgentStatusIndicatorProps) {
const config = statusConfig[status] || statusConfig.pending;
const config = statusConfig[status] || statusConfig.idle;
const sizeClasses = {
sm: 'h-2 w-2',

View File

@@ -24,7 +24,7 @@ import {
} from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Skeleton } from '@/components/ui/skeleton';
import type { IssueSummary as IssueSummaryType } from './types';
import type { IssueCountSummary } from './types';
// ============================================================================
// Types
@@ -32,7 +32,7 @@ import type { IssueSummary as IssueSummaryType } from './types';
interface IssueSummaryProps {
/** Issue summary data */
summary: IssueSummaryType | null;
summary: IssueCountSummary | null;
/** Whether data is loading */
isLoading?: boolean;
/** Callback when "View All Issues" is clicked */
@@ -171,8 +171,8 @@ export function IssueSummary({
<StatusRow
icon={CheckCircle2}
iconColor="text-green-500"
label="Completed"
count={summary.done}
label="Closed"
count={summary.closed}
/>
{onViewAllIssues && (

View File

@@ -28,7 +28,7 @@ import type {
AgentInstance,
Sprint,
BurndownDataPoint,
IssueSummary as IssueSummaryType,
IssueCountSummary,
ActivityItem,
} from './types';
@@ -52,7 +52,7 @@ const mockProject: Project = {
id: 'proj-001',
name: 'E-Commerce Platform Redesign',
description: 'Complete redesign of the e-commerce platform with modern UI/UX',
status: 'in_progress',
status: 'active',
autonomy_level: 'milestone',
current_sprint_id: 'sprint-003',
created_at: '2025-01-15T00:00:00Z',
@@ -66,7 +66,7 @@ const mockAgents: AgentInstance[] = [
project_id: 'proj-001',
name: 'Product Owner',
role: 'product_owner',
status: 'active',
status: 'working',
current_task: 'Reviewing user story acceptance criteria',
last_activity_at: new Date(Date.now() - 2 * 60 * 1000).toISOString(),
spawned_at: '2025-01-15T00:00:00Z',
@@ -102,7 +102,7 @@ const mockAgents: AgentInstance[] = [
project_id: 'proj-001',
name: 'Frontend Engineer',
role: 'frontend_engineer',
status: 'active',
status: 'working',
current_task: 'Implementing product catalog component',
last_activity_at: new Date(Date.now() - 1 * 60 * 1000).toISOString(),
spawned_at: '2025-01-15T00:00:00Z',
@@ -114,7 +114,7 @@ const mockAgents: AgentInstance[] = [
project_id: 'proj-001',
name: 'QA Engineer',
role: 'qa_engineer',
status: 'pending',
status: 'waiting',
current_task: 'Preparing test cases for Sprint 3',
last_activity_at: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
spawned_at: '2025-01-15T00:00:00Z',
@@ -148,12 +148,12 @@ const mockBurndownData: BurndownDataPoint[] = [
{ day: 8, remaining: 20, ideal: 24 },
];
const mockIssueSummary: IssueSummaryType = {
const mockIssueSummary: IssueCountSummary = {
open: 12,
in_progress: 8,
in_review: 3,
blocked: 2,
done: 45,
closed: 45,
total: 70,
};
@@ -392,7 +392,7 @@ export function ProjectDashboard({ projectId, className }: ProjectDashboardProps
<ProjectHeader
project={project}
isLoading={isLoading}
canPause={project.status === 'in_progress'}
canPause={project.status === 'active'}
canStart={true}
onStartSprint={handleStartSprint}
onPauseProject={handlePauseProject}

View File

@@ -84,7 +84,7 @@ export function ProjectHeader({
return null;
}
const showPauseButton = canPause && project.status === 'in_progress';
const showPauseButton = canPause && project.status === 'active';
const showStartButton = canStart && project.status !== 'completed' && project.status !== 'archived';
return (

View File

@@ -16,12 +16,8 @@ import type { ProjectStatus, AutonomyLevel } from './types';
// ============================================================================
const projectStatusConfig: Record<ProjectStatus, { label: string; className: string }> = {
draft: {
label: 'Draft',
className: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200',
},
in_progress: {
label: 'In Progress',
active: {
label: 'Active',
className: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
},
paused: {
@@ -32,10 +28,6 @@ const projectStatusConfig: Record<ProjectStatus, { label: string; className: str
label: 'Completed',
className: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
},
blocked: {
label: 'Blocked',
className: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
},
archived: {
label: 'Archived',
className: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
@@ -48,7 +40,7 @@ interface ProjectStatusBadgeProps {
}
export function ProjectStatusBadge({ status, className }: ProjectStatusBadgeProps) {
const config = projectStatusConfig[status] || projectStatusConfig.draft;
const config = projectStatusConfig[status] || projectStatusConfig.active;
return (
<Badge variant="outline" className={cn(config.className, className)}>

View File

@@ -11,7 +11,10 @@
// Project Types
// ============================================================================
export type ProjectStatus = 'draft' | 'in_progress' | 'paused' | 'completed' | 'blocked' | 'archived';
/**
* Matches backend: ProjectStatus enum in app/models/syndarix/enums.py
*/
export type ProjectStatus = 'active' | 'paused' | 'completed' | 'archived';
export type AutonomyLevel = 'full_control' | 'milestone' | 'autonomous';
@@ -31,7 +34,10 @@ export interface Project {
// Agent Types
// ============================================================================
export type AgentStatus = 'idle' | 'active' | 'working' | 'pending' | 'error' | 'terminated';
/**
* Matches backend: AgentStatus enum in app/models/syndarix/enums.py
*/
export type AgentStatus = 'idle' | 'working' | 'waiting' | 'paused' | 'terminated';
export interface AgentInstance {
id: string;
@@ -50,7 +56,10 @@ export interface AgentInstance {
// Sprint Types
// ============================================================================
export type SprintStatus = 'planning' | 'active' | 'review' | 'completed';
/**
* Matches backend: SprintStatus enum in app/models/syndarix/enums.py
*/
export type SprintStatus = 'planned' | 'active' | 'in_review' | 'completed' | 'cancelled';
export interface Sprint {
id: string;
@@ -78,7 +87,10 @@ export interface BurndownDataPoint {
// Issue Types
// ============================================================================
export type IssueStatus = 'open' | 'in_progress' | 'in_review' | 'blocked' | 'done' | 'closed';
/**
* Matches backend: IssueStatus enum in app/models/syndarix/enums.py
*/
export type IssueStatus = 'open' | 'in_progress' | 'in_review' | 'blocked' | 'closed';
export type IssuePriority = 'low' | 'medium' | 'high' | 'critical';
@@ -96,12 +108,12 @@ export interface Issue {
labels?: string[];
}
export interface IssueSummary {
export interface IssueCountSummary {
open: number;
in_progress: number;
in_review: number;
blocked: number;
done: number;
closed: number;
total: number;
}

View File

@@ -26,7 +26,6 @@ const STATUS_ICONS = {
in_progress: PlayCircle,
in_review: Clock,
blocked: AlertCircle,
done: CheckCircle2,
closed: XCircle,
} as const;

View File

@@ -22,14 +22,17 @@ export const STATUS_CONFIG: Record<IssueStatus, StatusConfig> = {
in_progress: { label: 'In Progress', color: 'text-yellow-500' },
in_review: { label: 'In Review', color: 'text-purple-500' },
blocked: { label: 'Blocked', color: 'text-red-500' },
done: { label: 'Done', color: 'text-green-500' },
closed: { label: 'Closed', color: 'text-muted-foreground' },
closed: { label: 'Closed', color: 'text-green-500' },
};
/**
* Priority configuration with labels and colors
*/
export const PRIORITY_CONFIG: Record<IssuePriority, PriorityConfig> = {
critical: {
label: 'Critical',
color: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
},
high: { label: 'High', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' },
medium: {
label: 'Medium',
@@ -46,10 +49,9 @@ export const STATUS_TRANSITIONS: StatusTransition[] = [
{ from: 'open', to: 'in_progress', label: 'Start Work' },
{ from: 'in_progress', to: 'in_review', label: 'Submit for Review' },
{ from: 'in_progress', to: 'blocked', label: 'Mark Blocked' },
{ from: 'in_review', to: 'done', label: 'Mark Done' },
{ from: 'in_review', to: 'closed', label: 'Complete' },
{ from: 'in_review', to: 'in_progress', label: 'Request Changes' },
{ from: 'blocked', to: 'in_progress', label: 'Unblock' },
{ from: 'done', to: 'closed', label: 'Close Issue' },
{ from: 'closed', to: 'open', label: 'Reopen' },
];
@@ -76,14 +78,13 @@ export const STATUS_ORDER: IssueStatus[] = [
'in_progress',
'in_review',
'blocked',
'done',
'closed',
];
/**
* All possible priorities in order
*/
export const PRIORITY_ORDER: IssuePriority[] = ['high', 'medium', 'low'];
export const PRIORITY_ORDER: IssuePriority[] = ['critical', 'high', 'medium', 'low'];
/**
* Sync status configuration

View File

@@ -94,7 +94,7 @@ function filterAndSortIssues(
case 'number':
return (a.number - b.number) * direction;
case 'priority': {
const priorityOrder = { high: 3, medium: 2, low: 1 };
const priorityOrder = { critical: 4, high: 3, medium: 2, low: 1 };
return (priorityOrder[a.priority] - priorityOrder[b.priority]) * direction;
}
case 'updated_at':

View File

@@ -16,6 +16,7 @@ export const mockIssues: IssueSummary[] = [
{
id: 'ISS-001',
number: 42,
type: 'story',
title: 'Implement user authentication flow',
description:
'Create complete authentication flow with login, register, and password reset.',
@@ -31,6 +32,7 @@ export const mockIssues: IssueSummary[] = [
{
id: 'ISS-002',
number: 43,
type: 'task',
title: 'Design product catalog component',
description: 'Create reusable product card and catalog grid components.',
status: 'in_review',
@@ -45,6 +47,7 @@ export const mockIssues: IssueSummary[] = [
{
id: 'ISS-003',
number: 44,
type: 'bug',
title: 'Fix cart total calculation bug',
description: 'Cart total shows incorrect amount when discount is applied.',
status: 'blocked',
@@ -60,6 +63,7 @@ export const mockIssues: IssueSummary[] = [
{
id: 'ISS-004',
number: 45,
type: 'story',
title: 'Add product search functionality',
description: 'Implement full-text search with filters for the product catalog.',
status: 'open',
@@ -74,9 +78,10 @@ export const mockIssues: IssueSummary[] = [
{
id: 'ISS-005',
number: 46,
type: 'task',
title: 'Optimize database queries for product listing',
description: 'Performance optimization for product queries with pagination.',
status: 'done',
status: 'closed',
priority: 'low',
labels: ['performance', 'backend', 'database'],
sprint: 'Sprint 2',
@@ -88,9 +93,10 @@ export const mockIssues: IssueSummary[] = [
{
id: 'ISS-006',
number: 47,
type: 'task',
title: 'Create checkout page wireframes',
description: 'Design wireframes for the checkout flow including payment selection.',
status: 'done',
status: 'closed',
priority: 'high',
labels: ['design', 'checkout', 'ui'],
sprint: 'Sprint 2',
@@ -102,6 +108,7 @@ export const mockIssues: IssueSummary[] = [
{
id: 'ISS-007',
number: 48,
type: 'story',
title: 'Implement responsive navigation',
description: 'Create mobile-friendly navigation with hamburger menu.',
status: 'open',
@@ -116,6 +123,7 @@ export const mockIssues: IssueSummary[] = [
{
id: 'ISS-008',
number: 49,
type: 'task',
title: 'Set up E2E test framework',
description: 'Configure Playwright for end-to-end testing.',
status: 'in_progress',
@@ -135,6 +143,7 @@ export const mockIssues: IssueSummary[] = [
export const mockIssueDetail: IssueDetail = {
id: 'ISS-001',
number: 42,
type: 'story',
title: 'Implement user authentication flow',
description: `## Overview
Create a complete authentication flow for the e-commerce platform.

View File

@@ -8,14 +8,22 @@
*/
/**
* Issue status values
* Issue type values (for hierarchy: Epic -> Story -> Task)
* Matches backend: IssueType enum in app/models/syndarix/enums.py
*/
export type IssueStatus = 'open' | 'in_progress' | 'in_review' | 'blocked' | 'done' | 'closed';
export type IssueType = 'epic' | 'story' | 'task' | 'bug';
/**
* Issue status values
* Matches backend: IssueStatus enum in app/models/syndarix/enums.py
*/
export type IssueStatus = 'open' | 'in_progress' | 'in_review' | 'blocked' | 'closed';
/**
* Issue priority values
* Matches backend: IssuePriority enum in app/models/syndarix/enums.py
*/
export type IssuePriority = 'high' | 'medium' | 'low';
export type IssuePriority = 'low' | 'medium' | 'high' | 'critical';
/**
* Sync status with external trackers
@@ -64,6 +72,7 @@ export interface IssueActivity {
export interface IssueSummary {
id: string;
number: number;
type: IssueType;
title: string;
description: string;
status: IssueStatus;

View File

@@ -10,7 +10,7 @@ const mockAgents: AgentInstance[] = [
project_id: 'proj-001',
name: 'Product Owner',
role: 'product_owner',
status: 'active',
status: 'working',
current_task: 'Reviewing user stories',
last_activity_at: new Date().toISOString(),
spawned_at: '2025-01-15T00:00:00Z',

View File

@@ -8,12 +8,6 @@ describe('AgentStatusIndicator', () => {
expect(indicator).toHaveClass('bg-yellow-500');
});
it('renders active status with correct color', () => {
const { container } = render(<AgentStatusIndicator status="active" />);
const indicator = container.querySelector('span > span');
expect(indicator).toHaveClass('bg-green-500');
});
it('renders working status with animation', () => {
const { container } = render(<AgentStatusIndicator status="working" />);
const indicator = container.querySelector('span > span');
@@ -21,16 +15,16 @@ describe('AgentStatusIndicator', () => {
expect(indicator).toHaveClass('animate-pulse');
});
it('renders pending status with correct color', () => {
const { container } = render(<AgentStatusIndicator status="pending" />);
it('renders waiting status with correct color', () => {
const { container } = render(<AgentStatusIndicator status="waiting" />);
const indicator = container.querySelector('span > span');
expect(indicator).toHaveClass('bg-gray-400');
expect(indicator).toHaveClass('bg-blue-500');
});
it('renders error status with correct color', () => {
const { container } = render(<AgentStatusIndicator status="error" />);
it('renders paused status with correct color', () => {
const { container } = render(<AgentStatusIndicator status="paused" />);
const indicator = container.querySelector('span > span');
expect(indicator).toHaveClass('bg-red-500');
expect(indicator).toHaveClass('bg-gray-400');
});
it('renders terminated status with correct color', () => {
@@ -40,41 +34,41 @@ describe('AgentStatusIndicator', () => {
});
it('applies small size by default', () => {
const { container } = render(<AgentStatusIndicator status="active" />);
const { container } = render(<AgentStatusIndicator status="working" />);
const indicator = container.querySelector('span > span');
expect(indicator).toHaveClass('h-2', 'w-2');
});
it('applies medium size when specified', () => {
const { container } = render(<AgentStatusIndicator status="active" size="md" />);
const { container } = render(<AgentStatusIndicator status="working" size="md" />);
const indicator = container.querySelector('span > span');
expect(indicator).toHaveClass('h-3', 'w-3');
});
it('applies large size when specified', () => {
const { container } = render(<AgentStatusIndicator status="active" size="lg" />);
const { container } = render(<AgentStatusIndicator status="working" size="lg" />);
const indicator = container.querySelector('span > span');
expect(indicator).toHaveClass('h-4', 'w-4');
});
it('shows label when showLabel is true', () => {
render(<AgentStatusIndicator status="active" showLabel />);
expect(screen.getByText('Active')).toBeInTheDocument();
render(<AgentStatusIndicator status="working" showLabel />);
expect(screen.getByText('Working')).toBeInTheDocument();
});
it('does not show label by default', () => {
render(<AgentStatusIndicator status="active" />);
expect(screen.queryByText('Active')).not.toBeInTheDocument();
render(<AgentStatusIndicator status="working" />);
expect(screen.queryByText('Working')).not.toBeInTheDocument();
});
it('has accessible status role and label', () => {
render(<AgentStatusIndicator status="active" />);
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Status: Active');
render(<AgentStatusIndicator status="working" />);
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Status: Working');
});
it('applies custom className', () => {
const { container } = render(
<AgentStatusIndicator status="active" className="custom-class" />
<AgentStatusIndicator status="working" className="custom-class" />
);
expect(container.firstChild).toHaveClass('custom-class');
});

View File

@@ -1,14 +1,14 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IssueSummary } from '@/components/projects/IssueSummary';
import type { IssueSummary as IssueSummaryType } from '@/components/projects/types';
import type { IssueCountSummary } from '@/components/projects/types';
const mockSummary: IssueSummaryType = {
const mockSummary: IssueCountSummary = {
open: 12,
in_progress: 8,
in_review: 3,
blocked: 2,
done: 45,
closed: 45,
total: 70,
};
@@ -33,7 +33,7 @@ describe('IssueSummary', () => {
expect(screen.getByText('Blocked')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('Completed')).toBeInTheDocument();
expect(screen.getByText('Closed')).toBeInTheDocument();
expect(screen.getByText('45')).toBeInTheDocument();
});

View File

@@ -7,7 +7,7 @@ const mockProject: Project = {
id: 'proj-001',
name: 'Test Project',
description: 'A test project for unit testing',
status: 'in_progress',
status: 'active',
autonomy_level: 'milestone',
created_at: '2025-01-15T00:00:00Z',
owner_id: 'user-001',
@@ -26,7 +26,7 @@ describe('ProjectHeader', () => {
it('renders project status badge', () => {
render(<ProjectHeader project={mockProject} />);
expect(screen.getByText('In Progress')).toBeInTheDocument();
expect(screen.getByText('Active')).toBeInTheDocument();
});
it('renders autonomy level badge', () => {
@@ -44,7 +44,7 @@ describe('ProjectHeader', () => {
expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0);
});
it('shows pause button when canPause is true and project is in_progress', () => {
it('shows pause button when canPause is true and project is active', () => {
const onPauseProject = jest.fn();
render(
<ProjectHeader
@@ -56,7 +56,7 @@ describe('ProjectHeader', () => {
expect(screen.getByRole('button', { name: /pause project/i })).toBeInTheDocument();
});
it('does not show pause button when project is not in_progress', () => {
it('does not show pause button when project is not active', () => {
const completedProject = { ...mockProject, status: 'completed' as const };
render(<ProjectHeader project={completedProject} canPause={true} />);
expect(screen.queryByRole('button', { name: /pause project/i })).not.toBeInTheDocument();

View File

@@ -2,9 +2,9 @@ import { render, screen } from '@testing-library/react';
import { ProjectStatusBadge, AutonomyBadge } from '@/components/projects/StatusBadge';
describe('ProjectStatusBadge', () => {
it('renders in_progress status correctly', () => {
render(<ProjectStatusBadge status="in_progress" />);
expect(screen.getByText('In Progress')).toBeInTheDocument();
it('renders active status correctly', () => {
render(<ProjectStatusBadge status="active" />);
expect(screen.getByText('Active')).toBeInTheDocument();
});
it('renders completed status correctly', () => {
@@ -12,21 +12,11 @@ describe('ProjectStatusBadge', () => {
expect(screen.getByText('Completed')).toBeInTheDocument();
});
it('renders blocked status correctly', () => {
render(<ProjectStatusBadge status="blocked" />);
expect(screen.getByText('Blocked')).toBeInTheDocument();
});
it('renders paused status correctly', () => {
render(<ProjectStatusBadge status="paused" />);
expect(screen.getByText('Paused')).toBeInTheDocument();
});
it('renders draft status correctly', () => {
render(<ProjectStatusBadge status="draft" />);
expect(screen.getByText('Draft')).toBeInTheDocument();
});
it('renders archived status correctly', () => {
render(<ProjectStatusBadge status="archived" />);
expect(screen.getByText('Archived')).toBeInTheDocument();
@@ -34,7 +24,7 @@ describe('ProjectStatusBadge', () => {
it('applies custom className', () => {
const { container } = render(
<ProjectStatusBadge status="in_progress" className="custom-class" />
<ProjectStatusBadge status="active" className="custom-class" />
);
expect(container.firstChild).toHaveClass('custom-class');
});

View File

@@ -11,6 +11,7 @@ const mockIssues: IssueSummary[] = [
{
id: 'issue-1',
number: 42,
type: 'bug',
title: 'Test Issue 1',
description: 'Description 1',
status: 'open',
@@ -25,6 +26,7 @@ const mockIssues: IssueSummary[] = [
{
id: 'issue-2',
number: 43,
type: 'story',
title: 'Test Issue 2',
description: 'Description 2',
status: 'in_progress',

View File

@@ -11,12 +11,11 @@ const statusLabels: Record<IssueStatus, string> = {
in_progress: 'In Progress',
in_review: 'In Review',
blocked: 'Blocked',
done: 'Done',
closed: 'Closed',
};
describe('StatusBadge', () => {
const statuses: IssueStatus[] = ['open', 'in_progress', 'in_review', 'blocked', 'done', 'closed'];
const statuses: IssueStatus[] = ['open', 'in_progress', 'in_review', 'blocked', 'closed'];
it.each(statuses)('renders %s status correctly', (status) => {
render(<StatusBadge status={status} />);

View File

@@ -22,7 +22,6 @@ describe('StatusWorkflow', () => {
expect(screen.getByText('In Progress')).toBeInTheDocument();
expect(screen.getByText('In Review')).toBeInTheDocument();
expect(screen.getByText('Blocked')).toBeInTheDocument();
expect(screen.getByText('Done')).toBeInTheDocument();
expect(screen.getByText('Closed')).toBeInTheDocument();
});