diff --git a/label_studio/projects/api.py b/label_studio/projects/api.py
index fb642411fa07..65dca4c7dc98 100644
--- a/label_studio/projects/api.py
+++ b/label_studio/projects/api.py
@@ -184,7 +184,11 @@ def get_queryset(self):
)
if filter in ['pinned_only', 'exclude_pinned']:
projects = projects.filter(pinned_at__isnull=filter == 'exclude_pinned')
- return ProjectManager.with_counts_annotate(projects, fields=fields).prefetch_related('members', 'created_by')
+ return (
+ ProjectManager.with_counts_annotate(projects, fields=fields)
+ .annotate_fsm_state()
+ .prefetch_related('members', 'created_by')
+ )
def get_serializer_context(self):
context = super(ProjectListAPI, self).get_serializer_context()
@@ -238,7 +242,11 @@ def get_queryset(self):
serializer = GetFieldsSerializer(data=self.request.query_params)
serializer.is_valid(raise_exception=True)
fields = serializer.validated_data.get('include')
- return Project.objects.with_counts(fields=fields).filter(organization=self.request.user.active_organization)
+ return (
+ Project.objects.with_counts(fields=fields)
+ .annotate_fsm_state()
+ .filter(organization=self.request.user.active_organization)
+ )
@method_decorator(
@@ -345,7 +353,7 @@ def get_queryset(self):
)
class ProjectAPI(generics.RetrieveUpdateDestroyAPIView):
parser_classes = (JSONParser, FormParser, MultiPartParser)
- queryset = Project.objects.with_counts()
+ queryset = Project.objects.with_counts().annotate_fsm_state()
permission_required = ViewClassPermission(
GET=all_permissions.projects_view,
DELETE=all_permissions.projects_delete,
@@ -362,7 +370,11 @@ def get_queryset(self):
serializer = GetFieldsSerializer(data=self.request.query_params)
serializer.is_valid(raise_exception=True)
fields = serializer.validated_data.get('include')
- return Project.objects.with_counts(fields=fields).filter(organization=self.request.user.active_organization)
+ return (
+ Project.objects.with_counts(fields=fields)
+ .annotate_fsm_state()
+ .filter(organization=self.request.user.active_organization)
+ )
def get(self, request, *args, **kwargs):
return super(ProjectAPI, self).get(request, *args, **kwargs)
diff --git a/label_studio/projects/models.py b/label_studio/projects/models.py
index fc9d5790ad52..da46a19fa37e 100644
--- a/label_studio/projects/models.py
+++ b/label_studio/projects/models.py
@@ -121,7 +121,7 @@ def with_state(self):
Example:
projects = Project.objects.with_state().filter(organization=org)
for project in projects:
- print(project.current_state) # No N+1 queries!
+ print(project.state) # No N+1 queries!
"""
return self.get_queryset().annotate_fsm_state()
diff --git a/label_studio/projects/serializers.py b/label_studio/projects/serializers.py
index 31429773c764..a0da9d1cb150 100644
--- a/label_studio/projects/serializers.py
+++ b/label_studio/projects/serializers.py
@@ -3,6 +3,7 @@
import bleach
from constants import SAFE_HTML_ATTRIBUTES, SAFE_HTML_TAGS
from django.db.models import Q
+from fsm.serializer_fields import FSMStateField
from label_studio_sdk.label_interface import LabelInterface
from label_studio_sdk.label_interface.control_tags import (
BrushLabelsTag,
@@ -97,6 +98,7 @@ class ProjectSerializer(FlexFieldsModelSerializer):
queue_total = serializers.SerializerMethodField()
queue_done = serializers.SerializerMethodField()
+ state = FSMStateField(read_only=True) # FSM state - automatically uses annotation if present
@property
def user_id(self):
@@ -249,6 +251,7 @@ class Meta:
'queue_total',
'queue_done',
'config_suitable_for_bulk_annotation',
+ 'state',
]
def validate_label_config(self, value):
diff --git a/web/apps/labelstudio/src/main.tsx b/web/apps/labelstudio/src/main.tsx
index c7449ae3c2f2..c5431c75f6c2 100644
--- a/web/apps/labelstudio/src/main.tsx
+++ b/web/apps/labelstudio/src/main.tsx
@@ -3,3 +3,4 @@ registerAnalytics();
import "./app/App";
import "./utils/service-worker";
+import "./utils/state-registry-lso";
diff --git a/web/apps/labelstudio/src/pages/Home/HomePage.tsx b/web/apps/labelstudio/src/pages/Home/HomePage.tsx
index 54f81f9ee6ee..054afdc6efaf 100644
--- a/web/apps/labelstudio/src/pages/Home/HomePage.tsx
+++ b/web/apps/labelstudio/src/pages/Home/HomePage.tsx
@@ -1,5 +1,5 @@
import { IconExternal, IconFolderAdd, IconHumanSignal, IconUserAdd, IconFolderOpen } from "@humansignal/icons";
-import { Button, SimpleCard, Spinner, Typography } from "@humansignal/ui";
+import { Button, SimpleCard, Spinner, Tooltip, Typography } from "@humansignal/ui";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { Link } from "react-router-dom";
@@ -212,7 +212,9 @@ function ProjectSimpleCard({
style={{ borderLeftColor: color }}
>
-
{project.title}
+
+ {project.title}
+
{finished} of {total} Tasks ({total > 0 ? Math.round((finished / total) * 100) : 0}%)
diff --git a/web/apps/labelstudio/src/pages/Projects/Projects.jsx b/web/apps/labelstudio/src/pages/Projects/Projects.jsx
index 6df47b083d47..2218c5f67abc 100644
--- a/web/apps/labelstudio/src/pages/Projects/Projects.jsx
+++ b/web/apps/labelstudio/src/pages/Projects/Projects.jsx
@@ -52,6 +52,7 @@ export const ProjectsPage = () => {
"color",
"is_published",
"assignment_settings",
+ "state",
].join(",");
const data = await api.callApi("projects", {
diff --git a/web/apps/labelstudio/src/pages/Projects/Projects.scss b/web/apps/labelstudio/src/pages/Projects/Projects.scss
index faca43cccba5..c545b28314b1 100644
--- a/web/apps/labelstudio/src/pages/Projects/Projects.scss
+++ b/web/apps/labelstudio/src/pages/Projects/Projects.scss
@@ -121,20 +121,37 @@
&__title {
display: flex;
+ flex-wrap: wrap;
font-size: 16px;
font-weight: 500;
margin-bottom: 0.25rem;
line-height: 22px;
align-items: center;
+ row-gap: 0.25rem;
+
+ &-text-wrapper {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ }
&-text {
- max-width: 250px;
+ flex: 1;
+ min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
+ &__state-chip {
+ width: 100%;
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ white-space: nowrap;
+ }
+
&__summary {
flex: 1;
font-size: 14px;
diff --git a/web/apps/labelstudio/src/pages/Projects/ProjectsList.jsx b/web/apps/labelstudio/src/pages/Projects/ProjectsList.jsx
index 7900c660b9bf..c491a95aac26 100644
--- a/web/apps/labelstudio/src/pages/Projects/ProjectsList.jsx
+++ b/web/apps/labelstudio/src/pages/Projects/ProjectsList.jsx
@@ -3,10 +3,11 @@ import { format } from "date-fns";
import { useMemo } from "react";
import { NavLink } from "react-router-dom";
import { IconCheck, IconEllipsis, IconMinus, IconSparks } from "@humansignal/icons";
-import { Userpic, Button, Dropdown } from "@humansignal/ui";
+import { Userpic, Button, Dropdown, Tooltip } from "@humansignal/ui";
import { Menu, Pagination } from "../../components";
import { cn } from "../../utils/bem";
import { absoluteURL } from "../../utils/helpers";
+import { ProjectStateChip } from "@humansignal/app-common";
const DEFAULT_CARD_COLORS = ["#FFFFFF", "#FDFDFC"];
@@ -80,7 +81,13 @@ const ProjectCard = ({ project }) => {
-
{project.title ?? "New project"}
+
+
+
+ {project.title ?? "New project"}
+
+
+
{
+
+ {project.state && (
+
+ )}
diff --git a/web/apps/labelstudio/src/utils/state-registry-lso.ts b/web/apps/labelstudio/src/utils/state-registry-lso.ts
new file mode 100644
index 000000000000..aed0c8f915d0
--- /dev/null
+++ b/web/apps/labelstudio/src/utils/state-registry-lso.ts
@@ -0,0 +1,84 @@
+/**
+ * Label Studio Open Source - State Registry
+ *
+ * This file registers core LSO project states with the state registry from app-common.
+ *
+ * The base registry (imported from @humansignal/app-common) provides:
+ * - StateRegistry class and singleton instance
+ * - StateType enum (INITIAL, IN_PROGRESS, ATTENTION, TERMINAL)
+ * - Helper functions (getStateColorClass, formatStateName, etc.)
+ *
+ * LSO supports a simplified state model with 3 core project states:
+ * - CREATED (initial state)
+ * - ANNOTATION_IN_PROGRESS (work in progress)
+ * - COMPLETED (terminal state)
+ *
+ * IMPORTANT: Import this file in your main.tsx or app initialization:
+ * ```typescript
+ * import './utils/state-registry-lso';
+ * ```
+ */
+
+import { stateRegistry, StateType } from "@humansignal/app-common";
+
+// ============================================================================
+// Project States (LSO Core)
+// ============================================================================
+
+/**
+ * LSO has a simplified state model with 3 core states.
+ * LSE extends this with additional workflow states (review, arbitration, etc.)
+ */
+stateRegistry.registerBatch({
+ CREATED: {
+ type: StateType.INITIAL,
+ label: "Created",
+ tooltips: {
+ project: "Project has been created and is ready for configuration",
+ },
+ },
+
+ ANNOTATION_IN_PROGRESS: {
+ type: StateType.IN_PROGRESS,
+ label: "In Progress",
+ tooltips: {
+ project: "Annotation work is in progress on this project",
+ task: "Task is being annotated",
+ },
+ },
+
+ COMPLETED: {
+ type: StateType.TERMINAL,
+ label: "Completed",
+ tooltips: {
+ project: "All work on this project is completed",
+ task: "Task has been completed",
+ },
+ },
+});
+
+// ============================================================================
+// Development Validation
+// ============================================================================
+
+/**
+ * In development mode, verify all expected LSO states are properly registered.
+ * This helps catch configuration issues early.
+ */
+if (process.env.NODE_ENV === "development") {
+ const lsoStates = ["CREATED", "ANNOTATION_IN_PROGRESS", "COMPLETED"];
+
+ const missingStates = lsoStates.filter((state) => !stateRegistry.isRegistered(state));
+
+ if (missingStates.length > 0) {
+ console.error("[LSO State Registry] Missing state registrations:", missingStates);
+ } else {
+ console.log("[LSO State Registry] ✅ All LSO states registered successfully");
+ console.log(`[LSO State Registry] Registered ${lsoStates.length} LSO states`);
+ }
+
+ // Log all registered states for debugging
+ if (process.env.DEBUG_STATE_REGISTRY) {
+ console.log("[LSO State Registry] All registered states:", stateRegistry.getAllStates());
+ }
+}