diff --git a/backend/alembic/versions/20260724_b7c1d2e3f4a5_add_loop_items_deleted_at.py b/backend/alembic/versions/20260724_b7c1d2e3f4a5_add_loop_items_deleted_at.py
new file mode 100644
index 0000000000..873ad10072
--- /dev/null
+++ b/backend/alembic/versions/20260724_b7c1d2e3f4a5_add_loop_items_deleted_at.py
@@ -0,0 +1,26 @@
+"""Add soft-delete timestamp to loop items.
+
+Revision ID: b7c1d2e3f4a5
+Revises: a6d94c3e5217
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision: str = "b7c1d2e3f4a5"
+down_revision: Union[str, None] = "a6d94c3e5217"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column("loop_items", sa.Column("deleted_at", sa.DateTime(), nullable=True))
+ op.create_index("ix_loop_items_deleted_at", "loop_items", ["deleted_at"])
+
+
+def downgrade() -> None:
+ op.drop_index("ix_loop_items_deleted_at", table_name="loop_items")
+ op.drop_column("loop_items", "deleted_at")
diff --git a/backend/app/api/endpoints/deliveries.py b/backend/app/api/endpoints/deliveries.py
index 3c33545845..250204d031 100644
--- a/backend/app/api/endpoints/deliveries.py
+++ b/backend/app/api/endpoints/deliveries.py
@@ -25,6 +25,7 @@
LoopItemCollaboratorResponse,
LoopItemCreate,
LoopItemListResponse,
+ LoopItemReorder,
LoopItemResponse,
LoopItemTaskBind,
LoopItemTaskBindingResponse,
@@ -194,6 +195,22 @@ def create_loop_item(
return LoopItemResponse.model_validate(item)
+@router.post(
+ "/cloud-projects/{project_id}/loop-items/reorder",
+ response_model=LoopItemListResponse,
+)
+def reorder_loop_items(
+ project_id: int,
+ values: LoopItemReorder,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+) -> LoopItemListResponse:
+ items = loop_item_service.reorder(db, project_id, current_user.id, values)
+ return LoopItemListResponse(
+ items=[LoopItemResponse.model_validate(item) for item in items]
+ )
+
+
@router.get("/loop-items/{item_id}", response_model=LoopItemResponse)
def get_loop_item(
item_id: str,
diff --git a/backend/app/mcp_server/server.py b/backend/app/mcp_server/server.py
index 41285062ef..f40f85e26e 100644
--- a/backend/app/mcp_server/server.py
+++ b/backend/app/mcp_server/server.py
@@ -22,6 +22,7 @@
"""
import contextvars
+import json
import logging
from contextlib import asynccontextmanager
from dataclasses import dataclass, replace
@@ -47,6 +48,7 @@
)
from app.mcp_server.context import (
MCPRequestContext,
+ get_token_info_from_context,
reset_mcp_context,
set_mcp_context,
)
@@ -69,6 +71,16 @@
SUBSCRIPTION_MCP_TRANSPORT_PATH = "/sse"
DELIVERY_MCP_MOUNT_PATH = "/mcp/delivery"
DELIVERY_MCP_TRANSPORT_PATH = "/sse"
+PROJECT_SPACE_PROTOCOL = "wegent.project-space"
+PROJECT_SPACE_PROTOCOL_VERSION = 1
+PROJECT_SPACE_CAPABILITIES = {
+ "projects.read": True,
+ "projects.create": True,
+ "todos.read": True,
+ "todos.write": True,
+ "files.read": True,
+ "deliveries.read": True,
+}
@dataclass(frozen=True)
@@ -529,7 +541,7 @@ def ensure_subscription_tools_registered() -> None:
# ============== Delivery MCP Server ==============
delivery_mcp_server = FastMCP(
- "wegent-delivery-mcp",
+ "wegent_delivery",
stateless_http=True,
json_response=True,
streamable_http_path="/",
@@ -542,7 +554,7 @@ def ensure_subscription_tools_registered() -> None:
def ensure_delivery_tools_registered() -> None:
- """Register AI-facing tools for authorized delivery snapshots."""
+ """Register the project-space tools and addressable cloud resources."""
global _delivery_tools_registered
if _delivery_tools_registered:
return
@@ -550,10 +562,58 @@ def ensure_delivery_tools_registered() -> None:
from app.mcp_server.tools import delivery # noqa: F401
count = register_tools_to_server(delivery_mcp_server, "delivery")
+ delivery_mcp_server.resource(
+ "cloud://projects",
+ name="Wegent project spaces",
+ description="Every project space accessible to the authenticated user.",
+ mime_type="application/json",
+ )(_read_cloud_projects_resource)
+ delivery_mcp_server.resource(
+ "cloud://projects/{project_id}",
+ name="Wegent project space",
+ description="A project space with its shared workspace and board items.",
+ mime_type="application/json",
+ )(_read_cloud_project_resource)
+ delivery_mcp_server.resource(
+ "cloud://projects/{project_id}/{resource_type}/{resource_id}",
+ name="Wegent project-space object",
+ description="A task, file, or delivery in a project space.",
+ mime_type="application/json",
+ )(_read_cloud_object_resource)
logger.info("[MCP:Delivery] Registered %s tools", count)
_delivery_tools_registered = True
+def _delivery_resource_token() -> MCPAuthInfo:
+ token_info = get_token_info_from_context()
+ if token_info is None:
+ raise PermissionError("Authentication required")
+ return token_info
+
+
+def _serialize_delivery_resource(reference: str) -> str:
+ from app.mcp_server.tools.delivery import resolve_cloud_reference
+
+ result = resolve_cloud_reference(reference, _delivery_resource_token())
+ return json.dumps(result, ensure_ascii=False, default=str)
+
+
+def _read_cloud_projects_resource() -> str:
+ return _serialize_delivery_resource("cloud://projects")
+
+
+def _read_cloud_project_resource(project_id: str) -> str:
+ return _serialize_delivery_resource(f"cloud://projects/{project_id}")
+
+
+def _read_cloud_object_resource(
+ project_id: str, resource_type: str, resource_id: str
+) -> str:
+ return _serialize_delivery_resource(
+ f"cloud://projects/{project_id}/{resource_type}/{resource_id}"
+ )
+
+
# ============== Starlette App Factory ==============
_SYSTEM_MCP_SPEC = McpAppSpec(
@@ -613,7 +673,7 @@ def ensure_delivery_tools_registered() -> None:
_DELIVERY_MCP_SPEC = McpAppSpec(
name="delivery",
- service_name="wegent-delivery-mcp",
+ service_name="wegent_delivery",
mount_path=DELIVERY_MCP_MOUNT_PATH,
transport_path=DELIVERY_MCP_TRANSPORT_PATH,
server=delivery_mcp_server,
@@ -644,7 +704,7 @@ def ensure_delivery_tools_registered() -> None:
def _build_root_metadata(spec: McpAppSpec) -> Dict[str, Any]:
- return {
+ metadata = {
"service": spec.service_name,
"transport": "streamable-http",
"endpoints": {
@@ -652,6 +712,15 @@ def _build_root_metadata(spec: McpAppSpec) -> Dict[str, Any]:
"health": f"{spec.mount_path}/health",
},
}
+ if spec.name == "delivery":
+ metadata.update(
+ {
+ "protocol": PROJECT_SPACE_PROTOCOL,
+ "protocolVersion": PROJECT_SPACE_PROTOCOL_VERSION,
+ "capabilities": PROJECT_SPACE_CAPABILITIES,
+ }
+ )
+ return metadata
def _build_mcp_app(spec: McpAppSpec) -> Starlette:
diff --git a/backend/app/mcp_server/tool_registry.py b/backend/app/mcp_server/tool_registry.py
index 79225cd57d..6f130548e4 100644
--- a/backend/app/mcp_server/tool_registry.py
+++ b/backend/app/mcp_server/tool_registry.py
@@ -130,7 +130,18 @@ async def tool_wrapper(**kwargs: Any) -> str:
f"[MCP:{server_name}] Tool {tool_name} failed: {e}",
exc_info=True,
)
- return json.dumps({"error": str(e)})
+ return json.dumps(
+ {
+ "error": {
+ "code": "MCP_TOOL_EXECUTION_FAILED",
+ "message": str(e),
+ "server": server_name,
+ "tool": tool_name,
+ "retryable": False,
+ }
+ },
+ ensure_ascii=False,
+ )
# Set function metadata for FastMCP
tool_wrapper.__name__ = tool_name
diff --git a/backend/app/mcp_server/tools/delivery.py b/backend/app/mcp_server/tools/delivery.py
index 2cc8b908ea..95c55a5313 100644
--- a/backend/app/mcp_server/tools/delivery.py
+++ b/backend/app/mcp_server/tools/delivery.py
@@ -10,7 +10,9 @@
from app.db.session import SessionLocal
from app.mcp_server.auth import MCPAuthInfo
from app.mcp_server.tools.decorator import mcp_tool
-from app.models.delivery import DeliveryAsset
+from app.models.delivery import DeliveryAsset, LoopItem, loop_datetime_value_is_unset
+from app.schemas.cloud_project import CloudProjectCreate
+from app.schemas.delivery import LoopItemCreate, LoopItemUpdate
from app.services.cloud_files import cloud_file_service
from app.services.cloud_projects import cloud_project_service
from app.services.delivery import delivery_service
@@ -19,6 +21,46 @@
TEXT_ASSET_LIMIT = 1024 * 1024
+def _serialize_todo(item: LoopItem) -> dict[str, Any]:
+ return {
+ "id": item.id,
+ "cloudProjectId": item.cloud_project_id,
+ "parentId": item.parent_id or None,
+ "title": item.title,
+ "description": item.description,
+ "status": item.status,
+ "priority": item.priority,
+ "assigneeUserId": item.assignee_user_id or None,
+ "dueAt": (None if loop_datetime_value_is_unset(item.due_at) else item.due_at),
+ "currentDeliveryId": item.current_delivery_id or None,
+ "version": item.version,
+ "createdByUserId": item.created_by_user_id,
+ "createdAt": item.created_at,
+ "updatedAt": item.updated_at,
+ "completedAt": (
+ None
+ if loop_datetime_value_is_unset(item.completed_at)
+ else item.completed_at
+ ),
+ "deletedAt": (
+ None if loop_datetime_value_is_unset(item.deleted_at) else item.deleted_at
+ ),
+ }
+
+
+def _serialize_collaborator(row: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "id": row["id"],
+ "loopItemId": row["loop_item_id"],
+ "userId": row["user_id"],
+ "userName": row["user_name"],
+ "email": row["email"] or None,
+ "source": row["source"],
+ "addedByUserId": row["added_by_user_id"],
+ "createdAt": row["created_at"],
+ }
+
+
@mcp_tool(
name="list_loop_item_deliveries",
description="List immutable deliveries available for a TODO or Loop Item.",
@@ -128,6 +170,37 @@ def list_cloud_projects(token_info: MCPAuthInfo) -> dict[str, Any]:
}
+@mcp_tool(
+ name="create_cloud_project",
+ description=(
+ "Create a cloud project space for sharing TODOs, deliveries, and files. "
+ "project_key (2-16 alphanumeric characters, uppercased automatically) sets "
+ "the task numbering prefix, e.g. WEG produces TODO ids like WEG-18; when "
+ "omitted a key is generated from the name. The creator automatically "
+ "becomes the project Owner."
+ ),
+ server="delivery",
+ exclude_params=["token_info"],
+)
+def create_cloud_project(
+ name: str,
+ token_info: MCPAuthInfo,
+ project_key: str | None = None,
+ description: str = "",
+) -> dict[str, Any]:
+ with SessionLocal() as db:
+ values = CloudProjectCreate(
+ name=name, project_key=project_key, description=description
+ )
+ project = cloud_project_service.create(db, token_info.user_id, values)
+ return {
+ "id": project.id,
+ "key": project.project_key,
+ "name": project.name,
+ "description": project.description,
+ }
+
+
@mcp_tool(
name="list_cloud_workspace",
description="List authorized shared files and folders in a cloud project.",
@@ -216,6 +289,222 @@ def list_cloud_todos(cloud_project_id: int, token_info: MCPAuthInfo) -> dict[str
}
+@mcp_tool(
+ name="get_cloud_todo",
+ description="Get full details of one TODO in an authorized cloud project.",
+ server="delivery",
+ exclude_params=["token_info"],
+)
+def get_cloud_todo(item_id: str, token_info: MCPAuthInfo) -> dict[str, Any]:
+ with SessionLocal() as db:
+ item = loop_item_service.get(db, item_id, token_info.user_id)
+ return _serialize_todo(item)
+
+
+@mcp_tool(
+ name="create_cloud_todo",
+ description=(
+ "Create a TODO in an authorized cloud project. Status must be one of "
+ "inbox, pending, in_progress, in_review, completed; priority one of "
+ "none, low, medium, high, urgent; due_at is an ISO 8601 datetime."
+ ),
+ server="delivery",
+ exclude_params=["token_info"],
+)
+def create_cloud_todo(
+ cloud_project_id: int,
+ title: str,
+ token_info: MCPAuthInfo,
+ description: str = "",
+ status: str = "inbox",
+ priority: str = "none",
+ assignee_user_id: int | None = None,
+ due_at: str | None = None,
+ parent_id: str | None = None,
+) -> dict[str, Any]:
+ with SessionLocal() as db:
+ values = LoopItemCreate(
+ title=title,
+ description=description,
+ status=status,
+ priority=priority,
+ assignee_user_id=assignee_user_id,
+ due_at=due_at,
+ parent_id=parent_id,
+ )
+ item = loop_item_service.create(
+ db, cloud_project_id, token_info.user_id, values
+ )
+ return _serialize_todo(item)
+
+
+@mcp_tool(
+ name="update_cloud_todo",
+ description=(
+ "Update a TODO with optimistic locking: pass the current version from "
+ "get_cloud_todo. Only provided fields are changed; fields left out keep "
+ "their current values."
+ ),
+ server="delivery",
+ exclude_params=["token_info"],
+)
+def update_cloud_todo(
+ item_id: str,
+ version: int,
+ token_info: MCPAuthInfo,
+ title: str | None = None,
+ description: str | None = None,
+ status: str | None = None,
+ priority: str | None = None,
+ assignee_user_id: int | None = None,
+ due_at: str | None = None,
+ parent_id: str | None = None,
+) -> dict[str, Any]:
+ with SessionLocal() as db:
+ provided = {
+ field: value
+ for field, value in {
+ "title": title,
+ "description": description,
+ "status": status,
+ "priority": priority,
+ "assignee_user_id": assignee_user_id,
+ "due_at": due_at,
+ "parent_id": parent_id,
+ }.items()
+ if value is not None
+ }
+ values = LoopItemUpdate(version=version, **provided)
+ item = loop_item_service.update(db, item_id, token_info.user_id, values)
+ return _serialize_todo(item)
+
+
+@mcp_tool(
+ name="delete_cloud_todo",
+ description=(
+ "Soft delete a TODO. The TODO moves to the recycle bin and can be "
+ "restored with restore_cloud_todo."
+ ),
+ server="delivery",
+ exclude_params=["token_info"],
+)
+def delete_cloud_todo(item_id: str, token_info: MCPAuthInfo) -> dict[str, Any]:
+ with SessionLocal() as db:
+ item = loop_item_service.delete(db, item_id, token_info.user_id)
+ return _serialize_todo(item)
+
+
+@mcp_tool(
+ name="restore_cloud_todo",
+ description="Restore a soft-deleted TODO from the recycle bin.",
+ server="delivery",
+ exclude_params=["token_info"],
+)
+def restore_cloud_todo(item_id: str, token_info: MCPAuthInfo) -> dict[str, Any]:
+ with SessionLocal() as db:
+ item = loop_item_service.restore(db, item_id, token_info.user_id)
+ return _serialize_todo(item)
+
+
+@mcp_tool(
+ name="list_cloud_todo_recycle_bin",
+ description=(
+ "List soft-deleted TODOs of an authorized cloud project, most recently "
+ "deleted first."
+ ),
+ server="delivery",
+ exclude_params=["token_info"],
+)
+def list_cloud_todo_recycle_bin(
+ cloud_project_id: int, token_info: MCPAuthInfo
+) -> dict[str, Any]:
+ with SessionLocal() as db:
+ items = loop_item_service.list_deleted(db, cloud_project_id, token_info.user_id)
+ return {"items": [_serialize_todo(item) for item in items]}
+
+
+@mcp_tool(
+ name="list_cloud_todo_collaborators",
+ description="List collaborators of a TODO in an authorized cloud project.",
+ server="delivery",
+ exclude_params=["token_info"],
+)
+def list_cloud_todo_collaborators(
+ item_id: str, token_info: MCPAuthInfo
+) -> dict[str, Any]:
+ with SessionLocal() as db:
+ rows = loop_item_service.list_collaborators(db, item_id, token_info.user_id)
+ return {"collaborators": [_serialize_collaborator(row) for row in rows]}
+
+
+@mcp_tool(
+ name="add_cloud_todo_collaborator",
+ description=(
+ "Add a cloud project member as a collaborator of a TODO. Requires "
+ "Developer permission on the project."
+ ),
+ server="delivery",
+ exclude_params=["token_info"],
+)
+def add_cloud_todo_collaborator(
+ item_id: str, collaborator_user_id: int, token_info: MCPAuthInfo
+) -> dict[str, Any]:
+ with SessionLocal() as db:
+ row = loop_item_service.add_collaborator(
+ db, item_id, collaborator_user_id, token_info.user_id
+ )
+ return _serialize_collaborator(row)
+
+
+@mcp_tool(
+ name="remove_cloud_todo_collaborator",
+ description=(
+ "Remove a collaborator from a TODO. Requires Developer permission on "
+ "the project."
+ ),
+ server="delivery",
+ exclude_params=["token_info"],
+)
+def remove_cloud_todo_collaborator(
+ item_id: str, collaborator_user_id: int, token_info: MCPAuthInfo
+) -> dict[str, Any]:
+ with SessionLocal() as db:
+ loop_item_service.remove_collaborator(
+ db, item_id, collaborator_user_id, token_info.user_id
+ )
+ return {"removed": True, "loopItemId": item_id, "userId": collaborator_user_id}
+
+
+@mcp_tool(
+ name="list_cloud_todo_attachments",
+ description="List attachments of a TODO in an authorized cloud project.",
+ server="delivery",
+ exclude_params=["token_info"],
+)
+def list_cloud_todo_attachments(
+ item_id: str, token_info: MCPAuthInfo
+) -> dict[str, Any]:
+ with SessionLocal() as db:
+ attachments = loop_item_service.list_attachments(
+ db, item_id, token_info.user_id
+ )
+ return {
+ "attachments": [
+ {
+ "id": attachment.id,
+ "loopItemId": attachment.loop_item_id,
+ "displayName": attachment.display_name,
+ "contentType": attachment.content_type or None,
+ "sizeBytes": attachment.size_bytes,
+ "sha256": attachment.sha256,
+ "createdByUserId": attachment.created_by_user_id,
+ "createdAt": attachment.created_at,
+ }
+ for attachment in attachments
+ ]
+ }
+
+
@mcp_tool(
name="resolve_cloud_reference",
description=(
@@ -231,7 +520,8 @@ def resolve_cloud_reference(reference: str, token_info: MCPAuthInfo) -> dict[str
return {"error": "Unsupported cloud reference"}
parts = [part for part in parsed.path.split("/") if part]
if not parts:
- return {"error": "Cloud project id is missing"}
+ # Generic cloud space reference: return every accessible project.
+ return list_cloud_projects(token_info)
try:
project_id = int(parts[0])
except ValueError:
diff --git a/backend/app/models/delivery.py b/backend/app/models/delivery.py
index ce58be7638..135b2657d1 100644
--- a/backend/app/models/delivery.py
+++ b/backend/app/models/delivery.py
@@ -122,6 +122,7 @@ class LoopNode(Base):
)
completed_at = Column(DateTime, nullable=True)
delivered_at = Column(DateTime, nullable=True)
+ deleted_at = Column(DateTime, nullable=True, index=True)
__mapper_args__ = {"polymorphic_on": resource_type, "polymorphic_identity": "node"}
__table_args__ = (
@@ -135,6 +136,17 @@ class LoopNode(Base):
class CloudProject(LoopNode):
__mapper_args__ = {"polymorphic_identity": "project"}
+ @property
+ def tags(self) -> list[str]:
+ """Project-level tag registry stored inside the metadata JSON column."""
+ metadata = self.metadata_json
+ if not isinstance(metadata, dict):
+ return []
+ tags = metadata.get("tags")
+ if not isinstance(tags, list):
+ return []
+ return [str(tag) for tag in tags]
+
def __init__(self, **kwargs: object) -> None:
kwargs.setdefault("status", "active")
kwargs.setdefault("next_item_number", 1)
@@ -144,6 +156,17 @@ def __init__(self, **kwargs: object) -> None:
class LoopItem(LoopNode):
__mapper_args__ = {"polymorphic_identity": "task"}
+ @property
+ def tags(self) -> list[str]:
+ """Item tags stored inside the metadata JSON column."""
+ metadata = self.metadata_json
+ if not isinstance(metadata, dict):
+ return []
+ tags = metadata.get("tags")
+ if not isinstance(tags, list):
+ return []
+ return [str(tag) for tag in tags]
+
class CloudProjectLocalBinding(LoopNode):
__mapper_args__ = {"polymorphic_identity": "local_binding"}
@@ -233,6 +256,7 @@ class DeliveryAsset(LoopNode):
"metadata_json": {},
"completed_at": _MYSQL_UNSET_DATETIME,
"delivered_at": _MYSQL_UNSET_DATETIME,
+ "deleted_at": _MYSQL_UNSET_DATETIME,
}
@@ -256,6 +280,11 @@ def loop_datetime_is_unset(column: object) -> object:
return or_(column.is_(None), column == _MYSQL_UNSET_DATETIME)
+def loop_datetime_value_is_unset(value: datetime | None) -> bool:
+ """Match an unset datetime value in both nullable and sentinel schemas."""
+ return value is None or value == _MYSQL_UNSET_DATETIME
+
+
@event.listens_for(LoopNode, "before_insert", propagate=True)
def _populate_mysql_non_null_defaults(
_mapper: object, connection: Connection, target: LoopNode
diff --git a/backend/app/schemas/cloud_project.py b/backend/app/schemas/cloud_project.py
index 267641356f..dbab6c5064 100644
--- a/backend/app/schemas/cloud_project.py
+++ b/backend/app/schemas/cloud_project.py
@@ -7,9 +7,17 @@
from datetime import datetime
from typing import Annotated
-from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, field_validator
+from pydantic import (
+ BaseModel,
+ BeforeValidator,
+ ConfigDict,
+ Field,
+ field_validator,
+ model_validator,
+)
from app.schemas.base_role import BaseRole
+from app.schemas.tagging import MAX_TAGS_PER_ITEM, normalize_tags
SnowflakeId = Annotated[str, BeforeValidator(str)]
@@ -30,8 +38,14 @@ def normalize_project_key(cls, value: str | None) -> str | None:
class CloudProjectUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
description: str | None = None
+ tags: list[str] | None = Field(default=None, max_length=MAX_TAGS_PER_ITEM)
version: int = Field(ge=1)
+ @field_validator("tags", mode="before")
+ @classmethod
+ def normalize_tag_list(cls, value: object) -> object:
+ return None if value is None else normalize_tags(value)
+
class CloudProjectResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -43,10 +57,21 @@ class CloudProjectResponse(BaseModel):
description: str
created_by_user_id: int
status: str
+ tags: list[str] = []
version: int
created_at: datetime
updated_at: datetime
+ @model_validator(mode="before")
+ @classmethod
+ def populate_tags(cls, value: object) -> object:
+ """Fill tags from the metadata JSON when the input has no tags key."""
+ if isinstance(value, dict) and "tags" not in value:
+ metadata = value.get("metadata_json")
+ tags = metadata.get("tags") if isinstance(metadata, dict) else None
+ return {**value, "tags": normalize_tags(tags)}
+ return value
+
class CloudProjectListResponse(BaseModel):
items: list[CloudProjectResponse]
diff --git a/backend/app/schemas/delivery.py b/backend/app/schemas/delivery.py
index 180c39a6a0..e65d7a3f22 100644
--- a/backend/app/schemas/delivery.py
+++ b/backend/app/schemas/delivery.py
@@ -7,9 +7,11 @@
from datetime import datetime
from typing import Any, Literal
-from pydantic import BaseModel, ConfigDict, Field, field_validator
+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.schemas.cloud_project import CloudProjectResponse, SnowflakeId
+from app.schemas.tagging import MAX_TAGS_PER_ITEM
+from app.schemas.tagging import normalize_tags as _normalize_tags
class LoopItemCreate(BaseModel):
@@ -22,6 +24,9 @@ class LoopItemCreate(BaseModel):
priority: Literal["none", "low", "medium", "high", "urgent"] = "none"
due_at: datetime | None = None
parent_id: str | None = Field(default=None, max_length=64)
+ tags: list[str] = Field(default_factory=list, max_length=MAX_TAGS_PER_ITEM)
+
+ _normalize = field_validator("tags", mode="before")(_normalize_tags)
class LoopItemUpdate(BaseModel):
@@ -35,6 +40,19 @@ class LoopItemUpdate(BaseModel):
priority: Literal["none", "low", "medium", "high", "urgent"] | None = None
due_at: datetime | None = None
parent_id: str | None = Field(default=None, max_length=64)
+ tags: list[str] | None = Field(default=None, max_length=MAX_TAGS_PER_ITEM)
+
+ _normalize = field_validator("tags", mode="before")(
+ lambda value: None if value is None else _normalize_tags(value)
+ )
+
+
+class LoopItemReorder(BaseModel):
+ """Manual order of the TODOs inside one board lane (parent + status)."""
+
+ parent_id: str | None = Field(default=None, max_length=64)
+ status: Literal["inbox", "pending", "in_progress", "in_review", "completed"]
+ item_ids: list[str] = Field(min_length=1, max_length=1000)
class LoopItemResponse(BaseModel):
@@ -51,6 +69,7 @@ class LoopItemResponse(BaseModel):
priority: str
due_at: datetime | None
sort_order: int
+ tags: list[str] = []
created_by_user_id: int
current_delivery_id: str | None
version: int
@@ -58,6 +77,16 @@ class LoopItemResponse(BaseModel):
updated_at: datetime
completed_at: datetime | None
+ @model_validator(mode="before")
+ @classmethod
+ def populate_tags(cls, value: object) -> object:
+ """Fill tags from the metadata JSON when the input has no tags key."""
+ if isinstance(value, dict) and "tags" not in value:
+ metadata = value.get("metadata_json")
+ tags = metadata.get("tags") if isinstance(metadata, dict) else None
+ return {**value, "tags": _normalize_tags(tags)}
+ return value
+
@field_validator("parent_id", "current_delivery_id", mode="before")
@classmethod
def normalize_empty_id(cls, value: object) -> object:
diff --git a/backend/app/schemas/tagging.py b/backend/app/schemas/tagging.py
new file mode 100644
index 0000000000..07362d6ac2
--- /dev/null
+++ b/backend/app/schemas/tagging.py
@@ -0,0 +1,20 @@
+# SPDX-FileCopyrightText: 2026 Weibo, Inc.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+"""Shared tag normalization for loop items and cloud projects."""
+
+MAX_TAGS_PER_ITEM = 20
+MAX_TAG_LENGTH = 32
+
+
+def normalize_tags(value: object) -> list[str]:
+ """Trim, dedupe, and cap tag lists; non-list input becomes empty."""
+ if not isinstance(value, list):
+ return []
+ tags: list[str] = []
+ for raw in value:
+ tag = str(raw).strip()[:MAX_TAG_LENGTH]
+ if tag and tag not in tags:
+ tags.append(tag)
+ return tags[:MAX_TAGS_PER_ITEM]
diff --git a/backend/app/services/cloud_projects/service.py b/backend/app/services/cloud_projects/service.py
index d0db86fca4..40dda7dfbb 100644
--- a/backend/app/services/cloud_projects/service.py
+++ b/backend/app/services/cloud_projects/service.py
@@ -117,6 +117,12 @@ def update(
db, project_id, user_id, BaseRole.Maintainer
).project
updates = values.model_dump(exclude={"version"}, exclude_none=True)
+ if "tags" in values.model_fields_set and values.tags is not None:
+ # The project tag registry lives inside the metadata JSON column;
+ # merge so other metadata keys survive the update.
+ metadata = dict(project.metadata_json or {})
+ metadata["tags"] = updates.pop("tags")
+ updates["metadata_json"] = metadata
updated = (
db.query(CloudProject)
.filter(
diff --git a/backend/app/services/loop_items/service.py b/backend/app/services/loop_items/service.py
index 201eb1d6ec..768efac96a 100644
--- a/backend/app/services/loop_items/service.py
+++ b/backend/app/services/loop_items/service.py
@@ -13,7 +13,7 @@
from typing import BinaryIO
from fastapi import HTTPException, status
-from sqlalchemy import select
+from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app.core.config import settings
@@ -27,13 +27,19 @@
LoopItemCollaborator,
adapt_loop_node_values_for_dialect,
loop_datetime_is_unset,
+ loop_datetime_value_is_unset,
)
from app.models.resource_member import MemberStatus, ResourceMember
from app.models.share_link import ResourceType
from app.models.task import TaskResource
from app.models.user import User
from app.schemas.base_role import BaseRole
-from app.schemas.delivery import LoopItemCreate, LoopItemTaskBind, LoopItemUpdate
+from app.schemas.delivery import (
+ LoopItemCreate,
+ LoopItemReorder,
+ LoopItemTaskBind,
+ LoopItemUpdate,
+)
from app.services.cloud_projects.access import require_cloud_project_role
from app.services.delivery.storage import delivery_storage
from app.stores.tasks import task_store
@@ -146,13 +152,17 @@ def create(
)
sequence = project.next_item_number
project.next_item_number += 1
+ payload = values.model_dump()
+ tags = payload.pop("tags")
item = LoopItem(
id=f"{project.project_key}-{sequence}",
cloud_project_id=project.id,
sequence_number=sequence,
created_by_user_id=user_id,
- **values.model_dump(),
+ **payload,
)
+ if tags:
+ item.metadata_json = {"tags": tags}
if item.status == "completed":
item.completed_at = self._now()
db.add(item)
@@ -164,13 +174,67 @@ def list(self, db: Session, cloud_project_id: int, user_id: int) -> list[LoopIte
require_cloud_project_role(db, cloud_project_id, user_id)
return (
db.query(LoopItem)
- .filter(LoopItem.cloud_project_id == cloud_project_id)
+ .filter(
+ LoopItem.cloud_project_id == cloud_project_id,
+ loop_datetime_is_unset(LoopItem.deleted_at),
+ )
+ .order_by(LoopItem.sort_order, LoopItem.updated_at.desc())
+ .all()
+ )
+
+ def reorder(
+ self,
+ db: Session,
+ cloud_project_id: int,
+ user_id: int,
+ values: LoopItemReorder,
+ ) -> list[LoopItem]:
+ """Persist the manual order of the TODOs in one board lane."""
+
+ require_cloud_project_role(db, cloud_project_id, user_id, BaseRole.Developer)
+ if values.parent_id is None:
+ # MySQL stores unset parent ids as empty strings, so match both.
+ parent_filter = or_(LoopItem.parent_id.is_(None), LoopItem.parent_id == "")
+ else:
+ parent_filter = LoopItem.parent_id == values.parent_id
+ lane = (
+ db.query(LoopItem)
+ .filter(
+ LoopItem.cloud_project_id == cloud_project_id,
+ LoopItem.status == values.status,
+ parent_filter,
+ loop_datetime_is_unset(LoopItem.deleted_at),
+ )
.order_by(LoopItem.sort_order, LoopItem.updated_at.desc())
.all()
)
+ by_id = {item.id: item for item in lane}
+ requested_ids = [item_id for item_id in values.item_ids if item_id in by_id]
+ if not requested_ids:
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_ENTITY, "TODO not found in lane"
+ )
+ # Lane members missing from the request (e.g. created concurrently)
+ # keep their relative order at the end of the lane.
+ ordered = [by_id[item_id] for item_id in requested_ids] + [
+ item for item in lane if item.id not in requested_ids
+ ]
+ for position, item in enumerate(ordered):
+ if item.sort_order != position:
+ item.sort_order = position
+ item.version += 1
+ db.commit()
+ return ordered
def get(self, db: Session, item_id: str, user_id: int) -> LoopItem:
- item = db.query(LoopItem).filter(LoopItem.id == item_id).first()
+ item = (
+ db.query(LoopItem)
+ .filter(
+ LoopItem.id == item_id,
+ loop_datetime_is_unset(LoopItem.deleted_at),
+ )
+ .first()
+ )
if item is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "TODO not found")
require_cloud_project_role(db, item.cloud_project_id, user_id)
@@ -283,11 +347,20 @@ def update(
updates = values.model_dump(exclude={"version"}, exclude_unset=True)
if "parent_id" in values.model_fields_set:
self._validate_parent_change(db, item, values.parent_id)
+ if "tags" in values.model_fields_set:
+ # Tags live inside the metadata JSON column; merge so other
+ # metadata keys survive the update.
+ metadata = dict(item.metadata_json or {})
+ metadata["tags"] = updates.pop("tags") or []
+ updates["metadata_json"] = metadata
next_status = updates.get("status")
if next_status and next_status != item.status:
updates["completed_at"] = (
self._now() if next_status == "completed" else None
)
+ # Reset the manual lane position so the TODO lands at the top of
+ # its new lane instead of an arbitrary stale position.
+ updates["sort_order"] = 0
updates = adapt_loop_node_values_for_dialect(
updates, db.get_bind().dialect.name
)
@@ -303,11 +376,57 @@ def update(
db.refresh(item)
return item
+ def delete(self, db: Session, item_id: str, user_id: int) -> LoopItem:
+ """Soft delete a TODO; the row is kept for the recycle bin."""
+
+ item = self.get(db, item_id, user_id)
+ require_cloud_project_role(
+ db, item.cloud_project_id, user_id, BaseRole.Developer
+ )
+ item.deleted_at = self._now()
+ item.version += 1
+ db.commit()
+ db.refresh(item)
+ return item
+
+ def restore(self, db: Session, item_id: str, user_id: int) -> LoopItem:
+ """Restore a soft-deleted TODO from the recycle bin."""
+
+ item = db.query(LoopItem).filter(LoopItem.id == item_id).first()
+ if item is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "TODO not found")
+ require_cloud_project_role(
+ db, item.cloud_project_id, user_id, BaseRole.Developer
+ )
+ if loop_datetime_value_is_unset(item.deleted_at):
+ raise HTTPException(status.HTTP_409_CONFLICT, "TODO is not deleted")
+ item.deleted_at = None
+ item.version += 1
+ db.commit()
+ db.refresh(item)
+ return item
+
+ def list_deleted(
+ self, db: Session, cloud_project_id: int, user_id: int
+ ) -> list[LoopItem]:
+ """List soft-deleted TODOs of a project, most recently deleted first."""
+
+ require_cloud_project_role(db, cloud_project_id, user_id)
+ return (
+ db.query(LoopItem)
+ .filter(
+ LoopItem.cloud_project_id == cloud_project_id,
+ ~loop_datetime_is_unset(LoopItem.deleted_at),
+ )
+ .order_by(LoopItem.deleted_at.desc())
+ .all()
+ )
+
def _require_parent(
self, db: Session, parent_id: str, cloud_project_id: int
) -> LoopItem:
parent = db.get(LoopItem, parent_id)
- if parent is None:
+ if parent is None or not loop_datetime_value_is_unset(parent.deleted_at):
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY, "Parent TODO not found"
)
@@ -614,6 +733,7 @@ def list_my_work(self, db: Session, user_id: int) -> list[dict[str, object]]:
db.query(LoopItem)
.filter(
LoopItem.cloud_project_id.in_(project_by_id),
+ loop_datetime_is_unset(LoopItem.deleted_at),
(LoopItem.assignee_user_id == user_id)
| LoopItem.id.in_(active_task_items)
| LoopItem.id.in_(collaborator_items),
diff --git a/backend/app/services/runtime_work_service.py b/backend/app/services/runtime_work_service.py
index 07c77362e1..28f9397f6c 100644
--- a/backend/app/services/runtime_work_service.py
+++ b/backend/app/services/runtime_work_service.py
@@ -3505,7 +3505,7 @@ def _build_runtime_execution_request(
)
execution_request.mcp_servers.append(
{
- "name": "wegent-delivery",
+ "name": "wegent_delivery",
"url": (
f"{settings.BACKEND_INTERNAL_URL.rstrip('/')}"
f"{settings.API_PREFIX}/mcp/delivery/sse"
@@ -3527,6 +3527,20 @@ def _message_with_application_context(
value = entry.get("value")
if isinstance(value, str) and value.strip():
entries.append(f"[{name}]\n{value.strip()}")
+ if "cloud://projects" in message and "projectSpaceCapability" not in (
+ context or {}
+ ):
+ entries.append(
+ "[projectSpaceCapability]\n"
+ "The user activated the Wegent project-space capability.\n"
+ "Use the wegent_delivery MCP server for project-space operations.\n"
+ "wegent_delivery is a server id, not a callable tool.\n"
+ "Use list_cloud_projects to list projects and create_cloud_project "
+ "to create one.\n"
+ "Use resolve_cloud_reference to resolve cloud:// references.\n"
+ "MCP resources describe addressable data; do not use "
+ "list_mcp_resources to discover tools."
+ )
if not entries:
return message
context_text = "\n\n".join(entries)
diff --git a/backend/tests/api/test_cloud_projects_api.py b/backend/tests/api/test_cloud_projects_api.py
index 83dfff2f46..64d3c04492 100644
--- a/backend/tests/api/test_cloud_projects_api.py
+++ b/backend/tests/api/test_cloud_projects_api.py
@@ -58,6 +58,55 @@ def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
+def test_cloud_project_tag_registry(
+ test_client: TestClient,
+ test_db: Session,
+ test_user: User,
+ test_token: str,
+) -> None:
+ created = test_client.post(
+ "/api/v1/cloud-projects",
+ headers=_auth(test_token),
+ json={"project_key": "preg", "name": "Tag registry"},
+ )
+ assert created.status_code == 201
+ project = created.json()
+ assert project["tags"] == []
+
+ updated = test_client.patch(
+ f"/api/v1/cloud-projects/{project['id']}",
+ headers=_auth(test_token),
+ json={
+ "version": project["version"],
+ "tags": [" 产品需求 ", "产品需求", "研发"],
+ },
+ )
+ assert updated.status_code == 200
+ assert updated.json()["tags"] == ["产品需求", "研发"]
+
+ listed = test_client.get("/api/v1/cloud-projects", headers=_auth(test_token))
+ assert listed.status_code == 200
+ match = next(item for item in listed.json()["items"] if item["id"] == project["id"])
+ assert match["tags"] == ["产品需求", "研发"]
+
+ # Updating other fields leaves the registry untouched.
+ renamed = test_client.patch(
+ f"/api/v1/cloud-projects/{project['id']}",
+ headers=_auth(test_token),
+ json={"version": updated.json()["version"], "name": "Renamed"},
+ )
+ assert renamed.status_code == 200
+ assert renamed.json()["tags"] == ["产品需求", "研发"]
+
+ cleared = test_client.patch(
+ f"/api/v1/cloud-projects/{project['id']}",
+ headers=_auth(test_token),
+ json={"version": renamed.json()["version"], "tags": []},
+ )
+ assert cleared.status_code == 200
+ assert cleared.json()["tags"] == []
+
+
def test_cloud_project_generates_key_when_omitted(
test_client: TestClient, test_token: str
) -> None:
@@ -211,6 +260,76 @@ def test_todo_lifecycle_and_multiple_local_tasks(
assert my_work.json()["items"][0]["has_active_task"] is True
+def test_loop_item_tags_roundtrip(
+ test_client: TestClient,
+ test_db: Session,
+ test_user: User,
+ test_token: str,
+) -> None:
+ project = test_client.post(
+ "/api/v1/cloud-projects",
+ headers=_auth(test_token),
+ json={"project_key": "tags", "name": "Tagged items"},
+ ).json()
+ created = test_client.post(
+ f"/api/v1/cloud-projects/{project['id']}/loop-items",
+ headers=_auth(test_token),
+ json={
+ "title": "Tagged item",
+ "assignee_user_id": test_user.id,
+ "tags": [" 产品需求 ", "产品需求", "研发", ""],
+ },
+ )
+ assert created.status_code == 201
+ item = created.json()
+ # Tags are trimmed, deduped, and empties dropped.
+ assert item["tags"] == ["产品需求", "研发"]
+
+ listed = test_client.get(
+ f"/api/v1/cloud-projects/{project['id']}/loop-items",
+ headers=_auth(test_token),
+ )
+ assert listed.status_code == 200
+ assert listed.json()["items"][0]["tags"] == ["产品需求", "研发"]
+
+ updated = test_client.patch(
+ f"/api/v1/loop-items/{item['id']}",
+ headers=_auth(test_token),
+ json={"version": item["version"], "tags": ["线上问题"]},
+ )
+ assert updated.status_code == 200
+ assert updated.json()["tags"] == ["线上问题"]
+
+ cleared = test_client.patch(
+ f"/api/v1/loop-items/{item['id']}",
+ headers=_auth(test_token),
+ json={"version": updated.json()["version"], "tags": []},
+ )
+ assert cleared.status_code == 200
+ assert cleared.json()["tags"] == []
+
+ my_work = test_client.get(
+ "/api/v1/cloud-work-items/my-work", headers=_auth(test_token)
+ )
+ assert my_work.status_code == 200
+ assert my_work.json()["items"][0]["tags"] == []
+
+ # Updates that omit tags must leave existing tags untouched.
+ tagged = test_client.patch(
+ f"/api/v1/loop-items/{item['id']}",
+ headers=_auth(test_token),
+ json={"version": cleared.json()["version"], "tags": ["产品需求"]},
+ )
+ assert tagged.status_code == 200
+ untouched = test_client.patch(
+ f"/api/v1/loop-items/{item['id']}",
+ headers=_auth(test_token),
+ json={"version": tagged.json()["version"], "title": "Retagged item"},
+ )
+ assert untouched.status_code == 200
+ assert untouched.json()["tags"] == ["产品需求"]
+
+
def test_cloud_project_owner_can_manage_members(
test_client: TestClient,
test_db: Session,
diff --git a/backend/tests/api/test_deliveries_api.py b/backend/tests/api/test_deliveries_api.py
index d65a2f9b65..81a1efe6e3 100644
--- a/backend/tests/api/test_deliveries_api.py
+++ b/backend/tests/api/test_deliveries_api.py
@@ -174,6 +174,70 @@ def test_loop_items_support_unbounded_hierarchy_and_reject_cycles(
assert cycle.json()["detail"] == "TODO hierarchy cannot contain a cycle"
+def test_loop_item_reorder_orders_one_lane(
+ test_client: TestClient,
+ test_token: str,
+ delivery_project: CloudProject,
+) -> None:
+ headers = _auth(test_token)
+
+ def create(title: str, status: str = "inbox") -> dict[str, Any]:
+ response = test_client.post(
+ f"/api/v1/cloud-projects/{delivery_project.id}/loop-items",
+ headers=headers,
+ json={"title": title, "status": status},
+ )
+ assert response.status_code == 201
+ return response.json()
+
+ first = create("First")
+ second = create("Second")
+ other_lane = create("Other lane", status="pending")
+
+ response = test_client.post(
+ f"/api/v1/cloud-projects/{delivery_project.id}/loop-items/reorder",
+ headers=headers,
+ json={
+ "parent_id": None,
+ "status": "inbox",
+ "item_ids": [second["id"], first["id"]],
+ },
+ )
+ assert response.status_code == 200
+ assert [item["id"] for item in response.json()["items"]] == [
+ second["id"],
+ first["id"],
+ ]
+ assert [item["sort_order"] for item in response.json()["items"]] == [0, 1]
+
+ listed = test_client.get(
+ f"/api/v1/cloud-projects/{delivery_project.id}/loop-items", headers=headers
+ ).json()["items"]
+ inbox_ids = [item["id"] for item in listed if item["status"] == "inbox"]
+ assert inbox_ids == [second["id"], first["id"]]
+ # The other lane keeps its own ordering state.
+ assert (
+ next(item for item in listed if item["id"] == other_lane["id"])["sort_order"]
+ == 0
+ )
+
+ # Moving a TODO to another lane resets its manual position to the top.
+ moved = test_client.patch(
+ f"/api/v1/loop-items/{second['id']}",
+ headers=headers,
+ json={"version": second["version"], "status": "pending"},
+ )
+ assert moved.status_code == 200
+ assert moved.json()["sort_order"] == 0
+
+ missing = test_client.post(
+ f"/api/v1/cloud-projects/{delivery_project.id}/loop-items/reorder",
+ headers=headers,
+ json={"parent_id": None, "status": "inbox", "item_ids": ["MISS-1"]},
+ )
+ assert missing.status_code == 422
+
+
def test_loop_item_parent_must_be_in_same_project(
test_client: TestClient,
test_token: str,
diff --git a/backend/tests/mcp_server/test_delivery_todo_tools.py b/backend/tests/mcp_server/test_delivery_todo_tools.py
new file mode 100644
index 0000000000..b3dce36ab5
--- /dev/null
+++ b/backend/tests/mcp_server/test_delivery_todo_tools.py
@@ -0,0 +1,383 @@
+# SPDX-FileCopyrightText: 2026 Weibo, Inc.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+"""Tests for cloud TODO (loop item) MCP tools on the delivery server."""
+
+import uuid
+from types import SimpleNamespace
+
+import pytest
+from fastapi import HTTPException
+from pydantic import ValidationError
+from sqlalchemy.orm import Session
+
+from app.core.security import get_password_hash
+from app.mcp_server.tools import delivery as delivery_tools
+from app.models.cloud_project import CloudProject
+from app.models.resource_member import MemberStatus, ResourceMember
+from app.models.share_link import ResourceType
+from app.models.user import User
+
+
+@pytest.fixture(autouse=True)
+def patch_session_local(monkeypatch: pytest.MonkeyPatch, test_db: Session) -> None:
+ monkeypatch.setattr(delivery_tools, "SessionLocal", lambda: test_db)
+
+
+@pytest.fixture
+def project(test_db: Session, test_user: User) -> CloudProject:
+ public_id = str(uuid.uuid4())
+ project = CloudProject(
+ public_id=public_id,
+ project_key="MCP",
+ name="MCP project",
+ description="",
+ created_by_user_id=test_user.id,
+ storage_prefix=f"projects/{public_id}",
+ )
+ test_db.add(project)
+ test_db.commit()
+ test_db.refresh(project)
+ return project
+
+
+@pytest.fixture
+def owner_info(test_user: User) -> SimpleNamespace:
+ return SimpleNamespace(user_id=test_user.id)
+
+
+@pytest.fixture
+def member_user(test_db: Session) -> User:
+ user = User(
+ user_name="memberuser",
+ password_hash=get_password_hash("memberpassword123"),
+ email="member@example.com",
+ is_active=True,
+ git_info=None,
+ )
+ test_db.add(user)
+ test_db.commit()
+ test_db.refresh(user)
+ return user
+
+
+def _add_member(db: Session, project: CloudProject, user: User, role: str) -> None:
+ db.add(
+ ResourceMember(
+ resource_type=ResourceType.CLOUD_PROJECT.value,
+ resource_id=project.id,
+ entity_type="user",
+ entity_id=str(user.id),
+ user_id=user.id,
+ role=role,
+ status=MemberStatus.APPROVED.value,
+ )
+ )
+ db.commit()
+
+
+def test_create_and_get_cloud_todo(
+ project: CloudProject, owner_info: SimpleNamespace
+) -> None:
+ created = delivery_tools.create_cloud_todo(
+ project.id,
+ "Write docs",
+ owner_info,
+ description="Document the MCP tools",
+ priority="high",
+ due_at="2026-01-02T03:04:05",
+ )
+
+ assert created["id"].startswith("MCP-")
+ assert created["title"] == "Write docs"
+ assert created["status"] == "inbox"
+ assert created["priority"] == "high"
+ assert created["version"] == 1
+ assert created["deletedAt"] is None
+ assert created["dueAt"].year == 2026
+
+ fetched = delivery_tools.get_cloud_todo(created["id"], owner_info)
+ assert fetched["description"] == "Document the MCP tools"
+
+ listed = delivery_tools.list_cloud_todos(project.id, owner_info)
+ assert [item["id"] for item in listed["items"]] == [created["id"]]
+
+
+def test_get_cloud_todo_missing_returns_404(
+ owner_info: SimpleNamespace,
+) -> None:
+ with pytest.raises(HTTPException) as exc_info:
+ delivery_tools.get_cloud_todo("MCP-999", owner_info)
+ assert exc_info.value.status_code == 404
+
+
+def test_update_cloud_todo_and_version_conflict(
+ project: CloudProject, owner_info: SimpleNamespace
+) -> None:
+ created = delivery_tools.create_cloud_todo(project.id, "Task", owner_info)
+
+ updated = delivery_tools.update_cloud_todo(
+ created["id"],
+ created["version"],
+ owner_info,
+ title="Task v2",
+ status="in_progress",
+ )
+ assert updated["title"] == "Task v2"
+ assert updated["status"] == "in_progress"
+ assert updated["version"] == created["version"] + 1
+
+ with pytest.raises(HTTPException) as exc_info:
+ delivery_tools.update_cloud_todo(
+ created["id"], created["version"], owner_info, title="stale"
+ )
+ assert exc_info.value.status_code == 409
+
+
+def test_deleted_todo_is_hidden_from_get_list_and_update(
+ project: CloudProject, owner_info: SimpleNamespace
+) -> None:
+ created = delivery_tools.create_cloud_todo(project.id, "Doomed", owner_info)
+
+ deleted = delivery_tools.delete_cloud_todo(created["id"], owner_info)
+ assert deleted["deletedAt"] is not None
+ assert deleted["version"] == created["version"] + 1
+
+ assert delivery_tools.list_cloud_todos(project.id, owner_info)["items"] == []
+
+ with pytest.raises(HTTPException) as exc_info:
+ delivery_tools.get_cloud_todo(created["id"], owner_info)
+ assert exc_info.value.status_code == 404
+
+ with pytest.raises(HTTPException) as exc_info:
+ delivery_tools.update_cloud_todo(
+ created["id"], deleted["version"], owner_info, title="nope"
+ )
+ assert exc_info.value.status_code == 404
+
+ # Deleting twice reports the TODO as gone instead of succeeding silently.
+ with pytest.raises(HTTPException) as exc_info:
+ delivery_tools.delete_cloud_todo(created["id"], owner_info)
+ assert exc_info.value.status_code == 404
+
+
+def test_recycle_bin_and_restore(
+ project: CloudProject, owner_info: SimpleNamespace
+) -> None:
+ first = delivery_tools.create_cloud_todo(project.id, "First", owner_info)
+ second = delivery_tools.create_cloud_todo(project.id, "Second", owner_info)
+ kept = delivery_tools.create_cloud_todo(project.id, "Kept", owner_info)
+ delivery_tools.delete_cloud_todo(first["id"], owner_info)
+ delivery_tools.delete_cloud_todo(second["id"], owner_info)
+
+ recycle_bin = delivery_tools.list_cloud_todo_recycle_bin(project.id, owner_info)
+ assert {item["id"] for item in recycle_bin["items"]} == {
+ first["id"],
+ second["id"],
+ }
+ assert all(item["deletedAt"] is not None for item in recycle_bin["items"])
+
+ restored = delivery_tools.restore_cloud_todo(first["id"], owner_info)
+ assert restored["deletedAt"] is None
+ assert restored["version"] == first["version"] + 2
+
+ listed_ids = {
+ item["id"]
+ for item in delivery_tools.list_cloud_todos(project.id, owner_info)["items"]
+ }
+ assert listed_ids == {first["id"], kept["id"]}
+
+ recycle_bin = delivery_tools.list_cloud_todo_recycle_bin(project.id, owner_info)
+ assert [item["id"] for item in recycle_bin["items"]] == [second["id"]]
+
+ # Restoring a TODO that is not deleted is a conflict.
+ with pytest.raises(HTTPException) as exc_info:
+ delivery_tools.restore_cloud_todo(first["id"], owner_info)
+ assert exc_info.value.status_code == 409
+
+
+def test_create_with_deleted_parent_is_rejected(
+ project: CloudProject, owner_info: SimpleNamespace
+) -> None:
+ parent = delivery_tools.create_cloud_todo(project.id, "Parent", owner_info)
+ delivery_tools.delete_cloud_todo(parent["id"], owner_info)
+
+ with pytest.raises(HTTPException) as exc_info:
+ delivery_tools.create_cloud_todo(
+ project.id, "Child", owner_info, parent_id=parent["id"]
+ )
+ assert exc_info.value.status_code == 422
+
+
+def test_write_tools_require_developer_role(
+ test_db: Session,
+ project: CloudProject,
+ owner_info: SimpleNamespace,
+ member_user: User,
+) -> None:
+ _add_member(test_db, project, member_user, "Reporter")
+ reporter_info = SimpleNamespace(user_id=member_user.id)
+ created = delivery_tools.create_cloud_todo(project.id, "Owned", owner_info)
+
+ # Reporter can read, including the recycle bin.
+ assert delivery_tools.get_cloud_todo(created["id"], reporter_info)["id"] == (
+ created["id"]
+ )
+ assert delivery_tools.list_cloud_todo_recycle_bin(project.id, reporter_info) == {
+ "items": []
+ }
+
+ with pytest.raises(HTTPException) as exc_info:
+ delivery_tools.create_cloud_todo(project.id, "Nope", reporter_info)
+ assert exc_info.value.status_code == 403
+
+ with pytest.raises(HTTPException) as exc_info:
+ delivery_tools.update_cloud_todo(
+ created["id"], created["version"], reporter_info, title="Nope"
+ )
+ assert exc_info.value.status_code == 403
+
+ with pytest.raises(HTTPException) as exc_info:
+ delivery_tools.delete_cloud_todo(created["id"], reporter_info)
+ assert exc_info.value.status_code == 403
+
+
+def test_tools_require_project_membership(
+ project: CloudProject,
+ owner_info: SimpleNamespace,
+ member_user: User,
+) -> None:
+ outsider_info = SimpleNamespace(user_id=member_user.id)
+ created = delivery_tools.create_cloud_todo(project.id, "Owned", owner_info)
+
+ with pytest.raises(HTTPException) as exc_info:
+ delivery_tools.get_cloud_todo(created["id"], outsider_info)
+ assert exc_info.value.status_code == 404
+
+ with pytest.raises(HTTPException) as exc_info:
+ delivery_tools.create_cloud_todo(project.id, "Nope", outsider_info)
+ assert exc_info.value.status_code == 404
+
+
+def test_collaborator_tools(
+ test_db: Session,
+ project: CloudProject,
+ owner_info: SimpleNamespace,
+ member_user: User,
+) -> None:
+ _add_member(test_db, project, member_user, "Reporter")
+ created = delivery_tools.create_cloud_todo(project.id, "Shared", owner_info)
+
+ added = delivery_tools.add_cloud_todo_collaborator(
+ created["id"], member_user.id, owner_info
+ )
+ assert added["userId"] == member_user.id
+ assert added["userName"] == "memberuser"
+
+ listed = delivery_tools.list_cloud_todo_collaborators(created["id"], owner_info)
+ assert [row["userId"] for row in listed["collaborators"]] == [member_user.id]
+
+ removed = delivery_tools.remove_cloud_todo_collaborator(
+ created["id"], member_user.id, owner_info
+ )
+ assert removed == {
+ "removed": True,
+ "loopItemId": created["id"],
+ "userId": member_user.id,
+ }
+
+ with pytest.raises(HTTPException) as exc_info:
+ delivery_tools.remove_cloud_todo_collaborator(
+ created["id"], member_user.id, owner_info
+ )
+ assert exc_info.value.status_code == 404
+
+
+def test_collaborator_target_must_be_project_member(
+ project: CloudProject,
+ owner_info: SimpleNamespace,
+ member_user: User,
+) -> None:
+ created = delivery_tools.create_cloud_todo(project.id, "Shared", owner_info)
+
+ with pytest.raises(HTTPException) as exc_info:
+ delivery_tools.add_cloud_todo_collaborator(
+ created["id"], member_user.id, owner_info
+ )
+ assert exc_info.value.status_code == 404
+
+
+def test_attachment_listing_and_deleted_item(
+ project: CloudProject, owner_info: SimpleNamespace
+) -> None:
+ created = delivery_tools.create_cloud_todo(project.id, "Docs", owner_info)
+
+ assert delivery_tools.list_cloud_todo_attachments(created["id"], owner_info) == {
+ "attachments": []
+ }
+
+ delivery_tools.delete_cloud_todo(created["id"], owner_info)
+ with pytest.raises(HTTPException) as exc_info:
+ delivery_tools.list_cloud_todo_attachments(created["id"], owner_info)
+ assert exc_info.value.status_code == 404
+
+
+def test_create_cloud_project_with_name_only(owner_info: SimpleNamespace) -> None:
+ created = delivery_tools.create_cloud_project("Side Project", owner_info)
+
+ assert created["name"] == "Side Project"
+ assert created["description"] == ""
+ # The key is generated from the name when project_key is omitted.
+ assert created["key"].startswith("SIDEPROJ")
+
+ # The creator is the Owner, so the project is listed as accessible.
+ listed = delivery_tools.list_cloud_projects(owner_info)
+ assert created["id"] in {item["id"] for item in listed["projects"]}
+
+
+def test_create_cloud_project_uppercases_project_key(
+ owner_info: SimpleNamespace,
+) -> None:
+ created = delivery_tools.create_cloud_project(
+ "Wegent", owner_info, project_key="weg", description="Agent OS"
+ )
+
+ assert created["key"] == "WEG"
+ assert created["description"] == "Agent OS"
+
+ # The key prefixes TODO numbering, e.g. WEG-18.
+ todo = delivery_tools.create_cloud_todo(created["id"], "First", owner_info)
+ assert todo["id"].startswith("WEG-")
+
+
+def test_create_cloud_project_rejects_invalid_project_key(
+ owner_info: SimpleNamespace,
+) -> None:
+ # Too short (min 2 characters).
+ with pytest.raises(ValidationError):
+ delivery_tools.create_cloud_project("Bad", owner_info, project_key="x")
+
+ # Non-alphanumeric characters are not allowed.
+ with pytest.raises(ValidationError):
+ delivery_tools.create_cloud_project("Bad", owner_info, project_key="W-E G")
+
+ # Too long (max 16 characters).
+ with pytest.raises(ValidationError):
+ delivery_tools.create_cloud_project("Bad", owner_info, project_key="X" * 17)
+
+
+def test_resolve_cloud_reference_without_project_id_lists_accessible_projects(
+ project: CloudProject,
+ owner_info: SimpleNamespace,
+) -> None:
+ # A bare `cloud://projects` reference resolves to every accessible project.
+ resolved = delivery_tools.resolve_cloud_reference("cloud://projects", owner_info)
+
+ assert "error" not in resolved
+ assert {
+ "id": project.id,
+ "key": project.project_key,
+ "name": project.name,
+ "description": project.description,
+ } in resolved["projects"]
diff --git a/backend/tests/mcp_server/test_delivery_tools.py b/backend/tests/mcp_server/test_delivery_tools.py
index 4bd68d0171..5b1c1d3fe9 100644
--- a/backend/tests/mcp_server/test_delivery_tools.py
+++ b/backend/tests/mcp_server/test_delivery_tools.py
@@ -16,14 +16,25 @@ def test_delivery_tools_are_registered_with_safe_public_parameters() -> None:
tools = get_registered_mcp_tools(server="delivery")
assert set(tools) == {
+ "add_cloud_todo_collaborator",
+ "create_cloud_project",
+ "create_cloud_todo",
+ "delete_cloud_todo",
+ "get_cloud_todo",
"list_cloud_projects",
+ "list_cloud_todo_attachments",
+ "list_cloud_todo_collaborators",
+ "list_cloud_todo_recycle_bin",
"list_cloud_todos",
"list_cloud_workspace",
"list_loop_item_deliveries",
"read_cloud_file",
"read_delivery_markdown",
"read_delivery_asset",
+ "remove_cloud_todo_collaborator",
"resolve_cloud_reference",
+ "restore_cloud_todo",
+ "update_cloud_todo",
}
assert [
parameter["name"]
@@ -52,6 +63,34 @@ def test_delivery_tools_receive_authenticated_request_context() -> None:
assert delivery_spec.allow_user_token is True
+def test_delivery_resources_match_cloud_reference_protocol() -> None:
+ from app.mcp_server.server import (
+ delivery_mcp_server,
+ ensure_delivery_tools_registered,
+ )
+
+ ensure_delivery_tools_registered()
+
+ resources = delivery_mcp_server._resource_manager.list_resources()
+ templates = delivery_mcp_server._resource_manager.list_templates()
+ assert {str(resource.uri) for resource in resources} == {"cloud://projects"}
+ assert {template.uri_template for template in templates} == {
+ "cloud://projects/{project_id}",
+ "cloud://projects/{project_id}/{resource_type}/{resource_id}",
+ }
+
+
+def test_delivery_metadata_declares_versioned_project_space_capabilities() -> None:
+ from app.mcp_server.server import _DELIVERY_MCP_SPEC, _build_root_metadata
+
+ metadata = _build_root_metadata(_DELIVERY_MCP_SPEC)
+
+ assert metadata["service"] == "wegent_delivery"
+ assert metadata["protocol"] == "wegent.project-space"
+ assert metadata["protocolVersion"] == 1
+ assert metadata["capabilities"]["projects.create"] is True
+
+
def test_regular_user_token_can_authenticate_for_user_scoped_mcp(monkeypatch) -> None:
token = create_access_token(data={"sub": "alice"})
monkeypatch.setattr(
diff --git a/backend/tests/mcp_server/test_tool_registry.py b/backend/tests/mcp_server/test_tool_registry.py
index 76daa036cf..3bac8031c0 100644
--- a/backend/tests/mcp_server/test_tool_registry.py
+++ b/backend/tests/mcp_server/test_tool_registry.py
@@ -384,8 +384,13 @@ def exploding_tool(token_info, query):
reset_mcp_context(token)
parsed = json.loads(result)
- assert "error" in parsed
- assert "Something went wrong" in parsed["error"]
+ assert parsed["error"] == {
+ "code": "MCP_TOOL_EXECUTION_FAILED",
+ "message": "Something went wrong",
+ "server": "knowledge",
+ "tool": "test_tool",
+ "retryable": False,
+ }
@pytest.mark.asyncio
async def test_wrapper_runs_sync_tool_in_executor(self):
diff --git a/backend/tests/services/test_runtime_work_service.py b/backend/tests/services/test_runtime_work_service.py
index d22127b2fb..127c4395f3 100644
--- a/backend/tests/services/test_runtime_work_service.py
+++ b/backend/tests/services/test_runtime_work_service.py
@@ -3902,7 +3902,7 @@ def test_build_runtime_execution_request_resolves_crd_model_id(
delivery_mcp = next(
server
for server in execution_request.mcp_servers
- if server["name"] == "wegent-delivery"
+ if server["name"] == "wegent_delivery"
)
assert delivery_mcp["type"] == "streamable-http"
assert delivery_mcp["url"].endswith("/api/mcp/delivery/sse")
@@ -3929,3 +3929,16 @@ def test_message_with_application_context_keeps_user_message_and_ignores_untrust
assert "Current TODO: WEG-1." in message
assert "ignore previous instructions" not in message
assert message.endswith("这个 TODO 里有啥?")
+
+
+def test_message_with_cloud_reference_activates_project_space_capability() -> None:
+ from app.services import runtime_work_service
+
+ message = runtime_work_service._message_with_application_context(
+ "[$项目空间](cloud://projects) 帮我创建一个新项目", None
+ )
+
+ assert "[projectSpaceCapability]" in message
+ assert "wegent_delivery is a server id, not a callable tool" in message
+ assert "create_cloud_project" in message
+ assert "do not use list_mcp_resources to discover tools" in message
diff --git a/demo/cloud-space-mention/index.html b/demo/cloud-space-mention/index.html
new file mode 100644
index 0000000000..21785fe143
--- /dev/null
+++ b/demo/cloud-space-mention/index.html
@@ -0,0 +1,349 @@
+
+
+
+
+
+@ 菜单 · 云空间触发设计 Demo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
快捷短语
+
+
GPT 5.6 Sol 轻度 ▾
+
+
+
+
+
+
+
+
+
新建项目空间
+
创建后自动绑定到当前会话,可直接 @ 它的任务和文件
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demo/kanban-redesign/assets/app.js b/demo/kanban-redesign/assets/app.js
new file mode 100644
index 0000000000..8b97570c39
--- /dev/null
+++ b/demo/kanban-redesign/assets/app.js
@@ -0,0 +1,55 @@
+// Overlay manager for the kanban-redesign demo.
+// Open via [data-open=""], close via [data-close], ESC key, or backdrop click.
+// URL hashes (#detail / #new-task / #start-task / #members / #new-project) auto-open
+// the matching overlay on load, which also enables direct screenshot checks.
+(function () {
+ 'use strict';
+
+ function overlayFor(id) {
+ return document.getElementById('overlay-' + id);
+ }
+
+ function open(id) {
+ var el = overlayFor(id);
+ if (el) el.classList.add('open');
+ }
+
+ function close(el) {
+ el.classList.remove('open');
+ }
+
+ function closeTopmost() {
+ var openOverlays = document.querySelectorAll('.overlay.open');
+ if (openOverlays.length) close(openOverlays[openOverlays.length - 1]);
+ }
+
+ document.addEventListener('click', function (e) {
+ var opener = e.target.closest('[data-open]');
+ if (opener) {
+ open(opener.getAttribute('data-open'));
+ return;
+ }
+ var closer = e.target.closest('[data-close]');
+ if (closer) {
+ var host = closer.closest('.overlay');
+ if (host) close(host);
+ return;
+ }
+ // Backdrop click closes the overlay.
+ if (e.target.classList && e.target.classList.contains('overlay')) {
+ close(e.target);
+ }
+ });
+
+ document.addEventListener('keydown', function (e) {
+ if (e.key === 'Escape') closeTopmost();
+ });
+
+ function openFromHash() {
+ var id = location.hash.replace(/^#/, '');
+ if (id && overlayFor(id)) open(id);
+ }
+
+ window.addEventListener('hashchange', openFromHash);
+ openFromHash();
+})();
diff --git a/demo/kanban-redesign/assets/my-work.js b/demo/kanban-redesign/assets/my-work.js
new file mode 100644
index 0000000000..0e88aeabd8
--- /dev/null
+++ b/demo/kanban-redesign/assets/my-work.js
@@ -0,0 +1,180 @@
+// "我的工作" view switcher: renders the list / calendar / timeline views from one
+// shared mock task list and toggles sections via the bottom switcher.
+(function () {
+ 'use strict';
+
+ var STATUS = {
+ todo: { label: '需要我处理', color: '#6366f1' },
+ doing: { label: '正在执行', color: '#f59e0b' },
+ review: { label: '等待确认', color: '#8b5cf6' },
+ done: { label: '已完成', color: '#10b981' }
+ };
+
+ var PRIORITY = {
+ high: { label: '高', cls: 'priority-high' },
+ mid: { label: '中', cls: 'priority-mid' },
+ low: { label: '低', cls: 'priority-low' }
+ };
+
+ // Dates are relative to "today" so the demo always looks alive.
+ function day(offset) {
+ var d = new Date();
+ d.setDate(d.getDate() + offset);
+ return d;
+ }
+ function iso(d) {
+ return d.getFullYear() + '-' +
+ String(d.getMonth() + 1).padStart(2, '0') + '-' +
+ String(d.getDate()).padStart(2, '0');
+ }
+
+ var TASKS = [
+ { id: 'A2C41F0-3', title: '看板页视觉方案初稿', project: 'Wegent V4', status: 'done', priority: 'mid', due: day(-2) },
+ { id: '1FFD6B9-2', title: '整理项目需求文档', project: '看板项目', status: 'review', priority: 'low', due: day(-1) },
+ { id: '1FFD6B9-3', title: '设计评审与走查', project: '看板项目', status: 'todo', priority: 'high', due: day(0) },
+ { id: '1FFD6B9-4', title: '核对看板列的拖拽排序', project: '看板项目', status: 'doing', priority: 'mid', due: day(0) },
+ { id: 'A2C41F0-1', title: '梳理 V4 版本的里程碑拆分', project: 'Wegent V4', status: 'todo', priority: 'high', due: day(1) },
+ { id: 'A2C41F0-2', title: '多页面静态 demo 搭建', project: 'Wegent V4', status: 'doing', priority: 'mid', due: day(2) },
+ { id: 'A2C41F0-5', title: '共享文件权限模型评审', project: 'Wegent V4', status: 'review', priority: 'mid', due: day(3) },
+ { id: 'A2C41F0-7', title: '确认执行器镜像的发布清单', project: 'Wegent V4', status: 'todo', priority: 'low', due: day(5) }
+ ];
+
+ var WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
+
+ function fmtDate(d) {
+ return (d.getMonth() + 1) + '月' + d.getDate() + '日';
+ }
+ function dayLabel(d) {
+ var today = new Date(); today.setHours(0, 0, 0, 0);
+ var target = new Date(d); target.setHours(0, 0, 0, 0);
+ var diff = Math.round((target - today) / 86400000);
+ var rel = diff === 0 ? '今天' : diff === 1 ? '明天' : diff === -1 ? '昨天' : null;
+ return (rel ? rel + ' · ' : '') + fmtDate(d) + ' ' + WEEKDAYS[d.getDay()];
+ }
+
+ /* ---------- List view ---------- */
+ function renderList() {
+ var body = document.getElementById('work-list-body');
+ if (!body) return;
+ var rows = TASKS.slice()
+ .sort(function (a, b) { return a.due - b.due; })
+ .map(function (t) {
+ var s = STATUS[t.status];
+ var p = PRIORITY[t.priority];
+ return '' +
+ '
' +
+ '' + t.id + '' +
+ '' + t.title + '' +
+ '
' +
+ '
' + t.project + '
' +
+ '
' +
+ '' + s.label +
+ '
' +
+ '
' + p.label + '
' +
+ '
' + fmtDate(t.due) + '
' +
+ '
';
+ });
+ body.innerHTML = rows.join('');
+ }
+
+ /* ---------- Calendar view (FullCalendar) ---------- */
+ var calendar = null;
+ function renderCalendar() {
+ var el = document.getElementById('work-calendar');
+ if (!el || typeof FullCalendar === 'undefined') return;
+ if (!calendar) {
+ calendar = new FullCalendar.Calendar(el, {
+ initialView: 'dayGridMonth',
+ locale: 'zh-cn',
+ height: 'auto',
+ headerToolbar: { left: 'prev,next today', center: 'title', right: 'dayGridMonth,dayGridWeek' },
+ buttonText: { today: '今天', month: '月', week: '周' },
+ events: TASKS.map(function (t) {
+ return {
+ title: t.title,
+ start: iso(t.due),
+ color: STATUS[t.status].color,
+ extendedProps: { taskId: t.id, project: t.project }
+ };
+ }),
+ eventContent: function (arg) {
+ return {
+ html: '' + arg.event.extendedProps.taskId + ' ' +
+ '' + arg.event.title + ''
+ };
+ }
+ });
+ calendar.render();
+ } else {
+ // The container was hidden; recalculate sizes when it becomes visible.
+ calendar.updateSize();
+ }
+ }
+
+ /* ---------- Timeline view ---------- */
+ function renderTimeline() {
+ var host = document.getElementById('work-timeline');
+ if (!host) return;
+ var byDay = {};
+ TASKS.forEach(function (t) {
+ var key = iso(t.due);
+ (byDay[key] = byDay[key] || []).push(t);
+ });
+ var keys = Object.keys(byDay).sort();
+ host.innerHTML = keys.map(function (key) {
+ var items = byDay[key].map(function (t) {
+ var s = STATUS[t.status];
+ return '' +
+ '
' +
+ '
' +
+ '
' +
+ '' + t.id + '' +
+ '' + t.title + '' +
+ '' + PRIORITY[t.priority].label + '' +
+ '
' +
+ '
' + t.project + ' · ' + s.label + '
' +
+ '
' +
+ '
';
+ }).join('');
+ var d = byDay[key][0].due;
+ return '' +
+ '
' + dayLabel(d) + '
' +
+ items +
+ '
';
+ }).join('');
+ }
+
+ /* ---------- Switcher ---------- */
+ var rendered = { group: true };
+ function switchView(name) {
+ document.querySelectorAll('.work-view').forEach(function (v) {
+ v.classList.toggle('active', v.getAttribute('data-view') === name);
+ });
+ document.querySelectorAll('.view-tab').forEach(function (b) {
+ var on = b.getAttribute('data-switch') === name;
+ b.classList.toggle('active', on);
+ b.setAttribute('aria-selected', on ? 'true' : 'false');
+ });
+ if (!rendered[name]) {
+ rendered[name] = true;
+ if (name === 'list') renderList();
+ if (name === 'calendar') renderCalendar();
+ if (name === 'timeline') renderTimeline();
+ } else if (name === 'calendar') {
+ renderCalendar(); // refresh size after being hidden
+ }
+ }
+
+ document.querySelectorAll('.view-tab').forEach(function (b) {
+ b.addEventListener('click', function () {
+ switchView(b.getAttribute('data-switch'));
+ });
+ });
+
+ // Pre-render list & timeline so they animate in instantly on first switch;
+ // the calendar renders lazily because FullCalendar needs a visible container.
+ renderList();
+ renderTimeline();
+ rendered.list = true;
+ rendered.timeline = true;
+})();
diff --git a/demo/kanban-redesign/assets/style.css b/demo/kanban-redesign/assets/style.css
new file mode 100644
index 0000000000..1b9c124031
--- /dev/null
+++ b/demo/kanban-redesign/assets/style.css
@@ -0,0 +1,1182 @@
+/* Shared design system for the kanban-redesign demo pages. */
+
+:root {
+ --bg: #fafafa;
+ --bg-elevated: #ffffff;
+ --bg-subtle: #f4f4f5;
+ --bg-hover: #f0f0f1;
+ --border: rgba(0, 0, 0, 0.07);
+ --border-strong: rgba(0, 0, 0, 0.12);
+ --text-1: #18181b;
+ --text-2: #52525b;
+ --text-3: #a1a1aa;
+ --accent: #18181b;
+ --accent-soft: rgba(24, 24, 27, 0.06);
+ --shadow-card: 0 1px 2px rgba(0,0,0,0.04), 0 1px 1px rgba(0,0,0,0.03);
+ --shadow-card-hover: 0 8px 24px rgba(0,0,0,0.08), 0 2px 6px rgba(0,0,0,0.04);
+ --shadow-pop: 0 16px 48px rgba(0,0,0,0.12);
+ --radius-s: 8px;
+ --radius-m: 12px;
+ --radius-l: 16px;
+ --sidebar-w: 248px;
+ --col-w: 292px;
+}
+
+* { margin: 0; padding: 0; box-sizing: border-box; -webkit-font-smoothing: antialiased; }
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC",
+ "Segoe UI", "Helvetica Neue", Arial, sans-serif;
+ background: var(--bg);
+ color: var(--text-1);
+ font-size: 14px;
+ line-height: 1.5;
+ height: 100vh;
+ overflow: hidden;
+ display: flex;
+}
+
+::selection { background: rgba(24,24,27,0.1); }
+
+/* ============ Sidebar ============ */
+.sidebar {
+ width: var(--sidebar-w);
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ background: var(--bg);
+ border-right: 1px solid var(--border);
+ padding: 14px 10px 0;
+ user-select: none;
+}
+
+.sidebar-brand {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ padding: 4px 10px 14px;
+ font-weight: 600;
+ font-size: 14px;
+ letter-spacing: -0.01em;
+}
+.sidebar-brand .logo {
+ width: 22px; height: 22px;
+ border-radius: 7px;
+ background: linear-gradient(135deg, #18181b 0%, #3f3f46 100%);
+ display: flex; align-items: center; justify-content: center;
+ color: #fff; font-size: 11px; font-weight: 700;
+}
+
+.nav-item {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ padding: 7px 10px;
+ border-radius: var(--radius-s);
+ color: var(--text-2);
+ font-size: 13.5px;
+ cursor: pointer;
+ text-decoration: none;
+ transition: background .15s ease, color .15s ease;
+ position: relative;
+}
+.nav-item svg { width: 16px; height: 16px; opacity: .75; flex-shrink: 0; }
+.nav-item:hover { background: var(--bg-hover); color: var(--text-1); }
+.nav-item.active { background: var(--accent-soft); color: var(--text-1); font-weight: 500; }
+.nav-item .kbd-hint {
+ margin-left: auto;
+ font-size: 11px;
+ color: var(--text-3);
+ border: 1px solid var(--border);
+ border-radius: 5px;
+ padding: 0 5px;
+ line-height: 17px;
+ background: var(--bg-elevated);
+}
+
+.sidebar-section {
+ margin-top: 22px;
+ padding: 0 10px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 11.5px;
+ font-weight: 500;
+ color: var(--text-3);
+ letter-spacing: 0.02em;
+}
+.sidebar-section .add-btn {
+ width: 20px; height: 20px;
+ border-radius: 6px;
+ display: flex; align-items: center; justify-content: center;
+ color: var(--text-3);
+ cursor: pointer;
+ transition: all .15s ease;
+}
+.sidebar-section .add-btn:hover { background: var(--bg-hover); color: var(--text-1); }
+
+.project-item {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ padding: 7px 10px;
+ margin-top: 4px;
+ border-radius: var(--radius-s);
+ color: var(--text-2);
+ font-size: 13.5px;
+ cursor: pointer;
+ text-decoration: none;
+ transition: all .15s ease;
+}
+.project-item:hover { background: var(--bg-hover); }
+.project-item.active { background: var(--accent-soft); color: var(--text-1); font-weight: 500; }
+.project-item .dot {
+ width: 7px; height: 7px; border-radius: 50%;
+ background: #6366f1;
+ flex-shrink: 0;
+}
+.project-item .count {
+ margin-left: auto;
+ font-size: 12px;
+ color: var(--text-3);
+ font-variant-numeric: tabular-nums;
+}
+
+.sidebar-footer {
+ margin-top: auto;
+ border-top: 1px solid var(--border);
+ padding: 10px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.avatar {
+ width: 30px; height: 30px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, #818cf8, #6366f1);
+ color: #fff;
+ display: flex; align-items: center; justify-content: center;
+ font-size: 12px; font-weight: 600;
+ flex-shrink: 0;
+}
+.user-meta { line-height: 1.3; min-width: 0; }
+.user-meta .name { font-size: 13px; font-weight: 500; color: var(--text-1); }
+.user-meta .email {
+ font-size: 11.5px; color: var(--text-3);
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+}
+
+/* ============ Main ============ */
+.main {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+/* Scroll container for list-style pages (projects / my-work / files). */
+.main-scroll {
+ flex: 1;
+ overflow-y: auto;
+ padding: 28px 32px 40px;
+}
+.page-narrow { max-width: 880px; margin: 0 auto; }
+
+.topbar {
+ height: 52px;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 0 20px;
+ border-bottom: 1px solid var(--border);
+ background: var(--bg-elevated);
+}
+.topbar .project-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-weight: 600;
+ font-size: 14px;
+ letter-spacing: -0.01em;
+}
+.topbar .project-title svg { width: 15px; height: 15px; color: var(--text-3); }
+
+.tabs {
+ display: flex;
+ gap: 4px;
+ background: var(--bg-subtle);
+ border-radius: 9px;
+ padding: 3px;
+}
+.tab {
+ padding: 4px 14px;
+ border-radius: 7px;
+ font-size: 13px;
+ color: var(--text-2);
+ cursor: pointer;
+ text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+ transition: all .15s ease;
+ border: none;
+ background: transparent;
+ font-family: inherit;
+}
+.tab:hover { color: var(--text-1); }
+.tab.active {
+ background: var(--bg-elevated);
+ color: var(--text-1);
+ font-weight: 500;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.08);
+}
+
+.topbar .spacer { flex: 1; }
+
+.icon-btn {
+ width: 30px; height: 30px;
+ border-radius: var(--radius-s);
+ border: none;
+ background: transparent;
+ color: var(--text-2);
+ display: flex; align-items: center; justify-content: center;
+ cursor: pointer;
+ transition: all .15s ease;
+}
+.icon-btn:hover { background: var(--bg-hover); color: var(--text-1); }
+.icon-btn svg { width: 16px; height: 16px; }
+.icon-btn.small { width: 26px; height: 26px; }
+.icon-btn.small svg { width: 14px; height: 14px; }
+
+.btn-primary {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 32px;
+ padding: 0 14px;
+ border-radius: 9px;
+ border: none;
+ background: var(--accent);
+ color: #fff;
+ font-size: 13px;
+ font-weight: 500;
+ font-family: inherit;
+ cursor: pointer;
+ transition: all .15s ease;
+ box-shadow: 0 1px 2px rgba(0,0,0,0.1);
+}
+.btn-primary:hover { background: #27272a; transform: translateY(-0.5px); box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
+.btn-primary:active { transform: translateY(0); }
+.btn-primary svg { width: 14px; height: 14px; }
+
+.btn-secondary {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 32px;
+ padding: 0 14px;
+ border-radius: 9px;
+ border: 1px solid var(--border-strong);
+ background: var(--bg-elevated);
+ color: var(--text-1);
+ font-size: 13px;
+ font-weight: 500;
+ font-family: inherit;
+ cursor: pointer;
+ transition: all .15s ease;
+}
+.btn-secondary:hover { background: var(--bg-hover); border-color: rgba(0,0,0,0.18); }
+.btn-secondary svg { width: 14px; height: 14px; }
+
+/* ============ Board ============ */
+.board-header {
+ padding: 22px 24px 14px;
+ display: flex;
+ align-items: baseline;
+ gap: 12px;
+}
+.board-header h1 {
+ font-size: 18px;
+ font-weight: 600;
+ letter-spacing: -0.02em;
+}
+.board-header .sub { font-size: 12.5px; color: var(--text-3); }
+
+.board {
+ flex: 1;
+ display: flex;
+ gap: 14px;
+ padding: 4px 24px 24px;
+ overflow-x: auto;
+ align-items: flex-start;
+}
+
+.column {
+ width: var(--col-w);
+ flex-shrink: 0;
+ background: var(--bg-subtle);
+ border-radius: var(--radius-l);
+ padding: 10px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ max-height: 100%;
+}
+
+.column-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 8px 8px;
+}
+.column-header .status-dot {
+ width: 8px; height: 8px; border-radius: 50%;
+ flex-shrink: 0;
+}
+.column-header .title { font-size: 13px; font-weight: 600; letter-spacing: -0.01em; }
+.column-header .count {
+ font-size: 12px;
+ color: var(--text-3);
+ font-variant-numeric: tabular-nums;
+}
+.column-header .col-add {
+ margin-left: auto;
+ width: 22px; height: 22px;
+ border-radius: 6px;
+ display: flex; align-items: center; justify-content: center;
+ color: var(--text-3);
+ cursor: pointer;
+ opacity: 0;
+ transition: all .15s ease;
+}
+.column:hover .col-add { opacity: 1; }
+.column-header .col-add:hover { background: var(--border); color: var(--text-1); }
+
+/* ============ Card ============ */
+.card {
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-m);
+ padding: 12px 13px;
+ box-shadow: var(--shadow-card);
+ cursor: pointer;
+ transition: box-shadow .18s ease, transform .18s ease, border-color .18s ease;
+}
+.card:hover {
+ box-shadow: var(--shadow-card-hover);
+ transform: translateY(-1px);
+ border-color: var(--border-strong);
+}
+.card-id {
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--text-3);
+ font-family: "SF Mono", ui-monospace, Menlo, monospace;
+ letter-spacing: 0.01em;
+}
+.card-title {
+ margin-top: 3px;
+ font-size: 14px;
+ font-weight: 500;
+ letter-spacing: -0.01em;
+}
+.card-meta {
+ margin-top: 10px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 11px;
+ font-weight: 500;
+ padding: 2px 8px;
+ border-radius: 99px;
+ background: var(--bg-subtle);
+ color: var(--text-2);
+}
+.badge.priority-high { background: #fef2f2; color: #dc2626; }
+.badge.priority-mid { background: #fffbeb; color: #d97706; }
+.badge.priority-low { background: var(--bg-subtle); color: var(--text-2); }
+.card-meta .date {
+ margin-left: auto;
+ font-size: 11.5px;
+ color: var(--text-3);
+ font-variant-numeric: tabular-nums;
+}
+.card-sub {
+ margin-top: 10px;
+ padding-top: 10px;
+ border-top: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ font-size: 12px;
+ color: var(--text-3);
+}
+.card-sub .sub-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ cursor: pointer;
+ color: var(--text-2);
+ transition: color .15s ease;
+}
+.card-sub .sub-link:hover { color: var(--text-1); }
+.card-sub .sub-link svg { width: 13px; height: 13px; }
+.card-sub .sub-add {
+ margin-left: auto;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--text-3);
+ cursor: pointer;
+ opacity: 0;
+ transition: all .15s ease;
+}
+.card:hover .sub-add { opacity: 1; }
+.card-sub .sub-add:hover { color: var(--text-1); }
+.card-sub .sub-add svg { width: 12px; height: 12px; }
+
+.add-card {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ padding: 9px 11px;
+ border-radius: var(--radius-m);
+ border: 1px dashed transparent;
+ color: var(--text-3);
+ font-size: 13px;
+ cursor: pointer;
+ transition: all .15s ease;
+}
+.add-card svg { width: 14px; height: 14px; }
+.add-card:hover {
+ background: var(--bg-elevated);
+ border-color: var(--border-strong);
+ color: var(--text-2);
+}
+
+.column-empty {
+ padding: 26px 12px;
+ text-align: center;
+ color: var(--text-3);
+ font-size: 12.5px;
+ border: 1px dashed var(--border-strong);
+ border-radius: var(--radius-m);
+}
+
+/* subtle entrance animation */
+@keyframes rise {
+ from { opacity: 0; transform: translateY(6px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+.card, .column-empty { animation: rise .3s ease both; }
+.column:nth-child(1) .card:nth-of-type(1) { animation-delay: .02s; }
+.column:nth-child(1) .card:nth-of-type(2) { animation-delay: .06s; }
+.column:nth-child(2) .card { animation-delay: .1s; }
+
+/* ============ Page header (list pages) ============ */
+.page-header {
+ display: flex;
+ align-items: flex-start;
+ gap: 16px;
+ margin-bottom: 22px;
+}
+.page-header .ph-text h1 {
+ font-size: 20px;
+ font-weight: 600;
+ letter-spacing: -0.02em;
+}
+.page-header .ph-text .sub {
+ margin-top: 4px;
+ font-size: 13px;
+ color: var(--text-3);
+}
+.page-header .ph-actions {
+ margin-left: auto;
+ display: flex;
+ gap: 8px;
+ flex-shrink: 0;
+}
+
+.page-foot-note {
+ margin-top: 18px;
+ font-size: 12px;
+ color: var(--text-3);
+}
+
+/* ============ Tables ============ */
+.table {
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-l);
+ box-shadow: var(--shadow-card);
+ overflow: hidden;
+}
+.trow {
+ display: grid;
+ align-items: center;
+ gap: 12px;
+ min-height: 46px;
+ padding: 0 18px;
+ border-top: 1px solid var(--border);
+ text-decoration: none;
+ color: var(--text-1);
+ transition: background .15s ease;
+}
+/* Column layouts per table type. */
+.cols-projects { grid-template-columns: 1fr 80px 120px 170px; }
+.cols-files { grid-template-columns: 1fr 110px 110px 90px 100px; }
+.cols-deliveries { grid-template-columns: 160px 1fr 110px 110px 90px; }
+.trow.thead {
+ min-height: 38px;
+ border-top: none;
+ font-size: 12px;
+ color: var(--text-3);
+ background: var(--bg);
+}
+a.trow:hover, .trow.clickable:hover { background: var(--bg-hover); }
+.trow .cell { min-width: 0; }
+.trow .cell-muted { color: var(--text-3); font-size: 12.5px; font-variant-numeric: tabular-nums; }
+.trow .name-cell {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-weight: 500;
+ min-width: 0;
+}
+.trow .name-cell svg { width: 16px; height: 16px; color: var(--text-3); flex-shrink: 0; }
+.trow .name-cell .dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
+.trow .row-actions {
+ display: flex;
+ gap: 2px;
+ justify-content: flex-end;
+ opacity: 0;
+ transition: opacity .15s ease;
+}
+.trow:hover .row-actions { opacity: 1; }
+
+.member-avatars { display: flex; align-items: center; }
+.member-avatars .avatar {
+ width: 22px; height: 22px;
+ font-size: 10px;
+ border: 2px solid var(--bg-elevated);
+ margin-left: -6px;
+}
+.member-avatars .avatar:first-child { margin-left: 0; }
+.member-avatars .count-text { margin-left: 8px; font-size: 12.5px; color: var(--text-3); }
+
+/* ============ Group cards (my-work) ============ */
+.group-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+}
+.group-card {
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-l);
+ box-shadow: var(--shadow-card);
+ overflow: hidden;
+}
+.group-card-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 13px 16px;
+ border-bottom: 1px solid var(--border);
+}
+.group-card-header .status-dot { width: 8px; height: 8px; border-radius: 50%; }
+.group-card-header .title { font-size: 13.5px; font-weight: 600; letter-spacing: -0.01em; }
+.group-card-header .count { font-size: 12px; color: var(--text-3); font-variant-numeric: tabular-nums; }
+.group-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 16px;
+ border-top: 1px solid var(--border);
+ cursor: pointer;
+ transition: background .15s ease;
+}
+.group-row:first-of-type { border-top: none; }
+.group-row:hover { background: var(--bg-hover); }
+.group-row .task-id {
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--text-3);
+ font-family: "SF Mono", ui-monospace, Menlo, monospace;
+ flex-shrink: 0;
+}
+.group-row .task-title {
+ font-size: 13px;
+ font-weight: 500;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.group-row .task-project {
+ margin-left: auto;
+ font-size: 12px;
+ color: var(--text-3);
+ flex-shrink: 0;
+}
+
+/* ============ Notice bar ============ */
+.notice-bar {
+ margin-top: 20px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 16px;
+ background: var(--bg-subtle);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-m);
+ font-size: 12.5px;
+ color: var(--text-2);
+}
+.notice-bar svg { width: 15px; height: 15px; color: var(--text-3); flex-shrink: 0; }
+
+/* ============ Section title (files page) ============ */
+.section-block { margin-top: 28px; }
+.section-block-header {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ margin-bottom: 12px;
+}
+.section-block-header h2 { font-size: 15px; font-weight: 600; letter-spacing: -0.01em; }
+.section-block-header .sub { font-size: 12.5px; color: var(--text-3); }
+
+/* ============ Overlays ============ */
+.overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 100;
+ background: rgba(0, 0, 0, 0.35);
+ backdrop-filter: blur(3px);
+ -webkit-backdrop-filter: blur(3px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity .18s ease, visibility .18s ease;
+}
+.overlay.open { opacity: 1; visibility: visible; }
+/* Higher layer for dialogs stacked above a drawer. */
+.overlay.overlay-top { z-index: 120; }
+
+.dialog {
+ width: 480px;
+ max-width: calc(100vw - 48px);
+ max-height: calc(100vh - 96px);
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-elevated);
+ border-radius: var(--radius-l);
+ box-shadow: var(--shadow-pop);
+ transform: translateY(8px);
+ transition: transform .2s ease;
+}
+.overlay.open .dialog { transform: translateY(0); }
+
+.dialog-header {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 18px 20px 0;
+}
+.dialog-header h2 { font-size: 15.5px; font-weight: 600; letter-spacing: -0.01em; }
+.dialog-header .sub { margin-top: 4px; font-size: 12.5px; color: var(--text-3); }
+.dialog-header .icon-btn { margin-left: auto; flex-shrink: 0; }
+
+.dialog-body { padding: 16px 20px; overflow-y: auto; }
+.dialog-desc {
+ font-size: 12.5px;
+ color: var(--text-3);
+ margin-bottom: 14px;
+ padding: 10px 12px;
+ background: var(--bg-subtle);
+ border-radius: var(--radius-s);
+}
+.dialog-footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ padding: 14px 20px;
+ border-top: 1px solid var(--border);
+}
+
+/* Form controls */
+.field { margin-bottom: 14px; }
+.field:last-child { margin-bottom: 0; }
+.field label {
+ display: block;
+ font-size: 12.5px;
+ font-weight: 500;
+ color: var(--text-2);
+ margin-bottom: 6px;
+}
+.input, .select, .textarea {
+ width: 100%;
+ height: 34px;
+ padding: 0 10px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-s);
+ background: var(--bg-elevated);
+ color: var(--text-1);
+ font-size: 13px;
+ font-family: inherit;
+ outline: none;
+ transition: border-color .15s ease, box-shadow .15s ease;
+}
+.input:focus, .select:focus, .textarea:focus {
+ border-color: var(--text-1);
+ box-shadow: 0 0 0 2px rgba(24,24,27,0.06);
+}
+.input::placeholder, .textarea::placeholder { color: var(--text-3); }
+.select {
+ appearance: none;
+ -webkit-appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2352525b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 10px center;
+ padding-right: 30px;
+ cursor: pointer;
+}
+.textarea {
+ height: auto;
+ min-height: 76px;
+ padding: 8px 10px;
+ resize: vertical;
+ line-height: 1.5;
+}
+.field-hint { margin-top: 6px; font-size: 12px; color: var(--text-3); }
+
+/* ============ Drawer (task detail) ============ */
+.overlay.drawer-overlay { justify-content: flex-end; }
+.drawer {
+ width: 560px;
+ max-width: calc(100vw - 48px);
+ height: calc(100% - 24px);
+ margin: 12px 12px 12px 0;
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-elevated);
+ border-radius: var(--radius-l);
+ box-shadow: var(--shadow-pop);
+ transform: translateX(16px);
+ transition: transform .2s ease;
+}
+.overlay.open .drawer { transform: translateX(0); }
+
+.drawer-header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 14px 18px;
+ border-bottom: 1px solid var(--border);
+}
+.drawer-header .task-id {
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text-2);
+ font-family: "SF Mono", ui-monospace, Menlo, monospace;
+ padding: 3px 8px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg-subtle);
+}
+.drawer-header .save-state { font-size: 12px; color: var(--text-3); }
+.drawer-header .icon-btn { margin-left: auto; }
+
+.drawer-body { flex: 1; overflow-y: auto; padding: 20px 22px; }
+.drawer-body > h1 {
+ font-size: 20px;
+ font-weight: 600;
+ letter-spacing: -0.02em;
+ margin-bottom: 18px;
+}
+
+.attr-grid {
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ gap: 10px;
+ padding: 12px 14px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-m);
+ background: var(--bg);
+ margin-bottom: 22px;
+}
+.attr-cell .attr-label { font-size: 11.5px; color: var(--text-3); margin-bottom: 4px; }
+.attr-cell .attr-value {
+ font-size: 12.5px;
+ font-weight: 500;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ white-space: nowrap;
+}
+.attr-cell .attr-value.muted { color: var(--text-3); font-weight: 400; }
+.attr-cell .status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
+
+.drawer-section { margin-bottom: 22px; }
+.drawer-section-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ margin-bottom: 10px;
+}
+.drawer-section-title .count { font-size: 12px; color: var(--text-3); font-weight: 400; }
+.drawer-section-title .link-add {
+ margin-left: auto;
+ font-size: 12px;
+ font-weight: 400;
+ color: var(--text-3);
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ transition: color .15s ease;
+}
+.drawer-section-title .link-add:hover { color: var(--text-1); }
+.drawer-section-title .link-add svg { width: 12px; height: 12px; }
+
+.drawer-section .md-text {
+ font-size: 13px;
+ color: var(--text-2);
+ line-height: 1.7;
+}
+.drawer-section .md-text p { margin-bottom: 8px; }
+.drawer-section .md-text strong { color: var(--text-1); }
+
+.detail-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 10px;
+ border-radius: var(--radius-s);
+ transition: background .15s ease;
+}
+.detail-row:hover { background: var(--bg-hover); }
+.detail-row .status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
+.detail-row .task-id {
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--text-3);
+ font-family: "SF Mono", ui-monospace, Menlo, monospace;
+ flex-shrink: 0;
+}
+.detail-row .row-title { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.detail-row .row-tail { margin-left: auto; display: flex; align-items: center; gap: 8px; flex-shrink: 0; font-size: 12px; color: var(--text-3); }
+.detail-row .avatar { width: 22px; height: 22px; font-size: 10px; }
+.detail-row .badge { flex-shrink: 0; }
+.detail-row svg.file-icon { width: 15px; height: 15px; color: var(--text-3); flex-shrink: 0; }
+
+.empty-hint {
+ padding: 18px 12px;
+ text-align: center;
+ font-size: 12.5px;
+ color: var(--text-3);
+ border: 1px dashed var(--border-strong);
+ border-radius: var(--radius-m);
+}
+
+.drawer-footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ padding: 12px 18px;
+ border-top: 1px solid var(--border);
+}
+
+/* Task summary card used inside the start-task dialog. */
+.task-summary {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-m);
+ background: var(--bg-subtle);
+ margin-bottom: 14px;
+}
+.task-summary .task-id {
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--text-3);
+ font-family: "SF Mono", ui-monospace, Menlo, monospace;
+}
+.task-summary .task-title { font-size: 13px; font-weight: 500; }
+
+/* Member rows inside the members dialog. */
+.member-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 9px 0;
+ border-bottom: 1px solid var(--border);
+}
+.member-row:last-child { border-bottom: none; }
+.member-row .member-meta { min-width: 0; }
+.member-row .member-meta .name { font-size: 13px; font-weight: 500; }
+.member-row .member-meta .email { font-size: 11.5px; color: var(--text-3); }
+.member-row .select {
+ width: 96px;
+ height: 28px;
+ font-size: 12px;
+ margin-left: auto;
+}
+.member-row .owner-tag {
+ margin-left: auto;
+ font-size: 12px;
+ color: var(--text-2);
+ width: 96px;
+ text-align: left;
+ padding-left: 10px;
+}
+.member-row .icon-btn { flex-shrink: 0; }
+
+.add-member {
+ display: flex;
+ gap: 8px;
+ margin-top: 14px;
+ padding-top: 14px;
+ border-top: 1px solid var(--border);
+}
+.add-member .input { flex: 1; }
+.add-member .select { width: 96px; }
+
+/* scrollbar */
+::-webkit-scrollbar { width: 8px; height: 8px; }
+::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.12); border-radius: 99px; }
+::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.2); }
+::-webkit-scrollbar-track { background: transparent; }
+
+/* ============ My Work: view switcher & views ============ */
+.main-scroll.has-view-switcher { padding-bottom: 96px; }
+
+.work-view { display: none; }
+.work-view.active { display: block; animation: rise .25s ease both; }
+
+.view-switcher {
+ position: fixed;
+ bottom: 18px;
+ left: calc(var(--sidebar-w) + (100vw - var(--sidebar-w)) / 2);
+ transform: translateX(-50%);
+ display: flex;
+ gap: 2px;
+ padding: 4px;
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ box-shadow: 0 8px 28px rgba(0,0,0,0.12), 0 2px 6px rgba(0,0,0,0.06);
+ z-index: 50;
+}
+.view-tab {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 30px;
+ padding: 0 14px;
+ border: none;
+ border-radius: 9px;
+ background: transparent;
+ color: var(--text-2);
+ font-size: 12.5px;
+ font-weight: 500;
+ font-family: inherit;
+ cursor: pointer;
+ transition: all .15s ease;
+}
+.view-tab svg { width: 14px; height: 14px; opacity: .8; }
+.view-tab:hover { color: var(--text-1); background: var(--bg-hover); }
+.view-tab.active {
+ background: var(--accent);
+ color: #fff;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.18);
+}
+.view-tab.active svg { opacity: 1; }
+
+/* List view columns & status pill */
+.cols-work { grid-template-columns: 1fr 110px 120px 80px 90px; }
+.cols-work .task-id {
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--text-3);
+ font-family: "SF Mono", ui-monospace, Menlo, monospace;
+ flex-shrink: 0;
+}
+.cols-work .cell-title {
+ font-weight: 500;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.status-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text-2);
+}
+.status-pill .status-dot { width: 7px; height: 7px; border-radius: 50%; }
+
+/* Calendar view card */
+.calendar-card {
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-l);
+ box-shadow: var(--shadow-card);
+ padding: 16px 18px 10px;
+}
+
+/* FullCalendar theme overrides to match the design system */
+.calendar-card .fc {
+ --fc-border-color: var(--border);
+ --fc-today-bg-color: var(--accent-soft);
+ --fc-page-bg-color: transparent;
+ --fc-neutral-bg-color: var(--bg-subtle);
+ --fc-list-event-hover-bg-color: var(--bg-hover);
+ font-size: 12.5px;
+}
+.calendar-card .fc .fc-toolbar-title { font-size: 15px; font-weight: 600; letter-spacing: -0.01em; }
+.calendar-card .fc .fc-button {
+ background: var(--bg-elevated);
+ border: 1px solid var(--border-strong);
+ color: var(--text-1);
+ font-size: 12px;
+ font-weight: 500;
+ padding: 4px 10px;
+ border-radius: 8px;
+ box-shadow: none;
+ text-transform: none;
+}
+.calendar-card .fc .fc-button:hover { background: var(--bg-hover); }
+.calendar-card .fc .fc-button-primary:not(:disabled).fc-button-active,
+.calendar-card .fc .fc-button-primary:not(:disabled):active {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: #fff;
+}
+.calendar-card .fc .fc-button:focus { box-shadow: none; }
+.calendar-card .fc .fc-col-header-cell {
+ font-size: 11.5px;
+ font-weight: 500;
+ color: var(--text-3);
+ background: var(--bg);
+ padding: 6px 0;
+}
+.calendar-card .fc .fc-daygrid-day-number {
+ font-size: 12px;
+ color: var(--text-2);
+ padding: 6px 8px;
+}
+.calendar-card .fc .fc-day-today .fc-daygrid-day-number {
+ background: var(--accent);
+ color: #fff;
+ border-radius: 99px;
+ min-width: 22px; height: 22px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ margin: 4px;
+ padding: 0 5px;
+ white-space: nowrap;
+}
+.calendar-card .fc .fc-daygrid-event {
+ border-radius: 6px;
+ padding: 1px 6px;
+ font-size: 11.5px;
+ margin-top: 2px;
+ white-space: nowrap;
+ overflow: hidden;
+}
+.calendar-card .fc .fc-daygrid-event-dot { display: none; }
+.calendar-card .fc .fc-task-id {
+ font-family: "SF Mono", ui-monospace, Menlo, monospace;
+ font-size: 10px;
+ opacity: .75;
+ margin-right: 2px;
+}
+
+/* Timeline view */
+.tl-day { position: relative; padding-left: 20px; margin-bottom: 22px; }
+.tl-day::before {
+ content: '';
+ position: absolute;
+ left: 4px;
+ top: 26px;
+ bottom: -8px;
+ width: 1.5px;
+ background: var(--border-strong);
+}
+.tl-day:last-child::before { display: none; }
+.tl-day-label {
+ font-size: 12.5px;
+ font-weight: 600;
+ color: var(--text-1);
+ margin-bottom: 10px;
+ letter-spacing: -0.01em;
+}
+.tl-item {
+ position: relative;
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ margin-bottom: 8px;
+}
+.tl-item .tl-dot {
+ position: absolute;
+ left: -20px;
+ top: 14px;
+ width: 9px; height: 9px;
+ border-radius: 50%;
+ border: 2px solid var(--bg);
+ box-sizing: content-box;
+ margin-left: -2.25px;
+}
+.tl-card {
+ flex: 1;
+ min-width: 0;
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-m);
+ box-shadow: var(--shadow-card);
+ padding: 10px 14px;
+ cursor: pointer;
+ transition: box-shadow .18s ease, border-color .18s ease;
+}
+.tl-card:hover { box-shadow: var(--shadow-card-hover); border-color: var(--border-strong); }
+.tl-card-top {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+}
+.tl-card-top .task-id {
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--text-3);
+ font-family: "SF Mono", ui-monospace, Menlo, monospace;
+ flex-shrink: 0;
+}
+.tl-card-top .tl-title {
+ font-size: 13.5px;
+ font-weight: 500;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.tl-card-top .badge { margin-left: auto; flex-shrink: 0; }
+.tl-card-meta {
+ margin-top: 4px;
+ font-size: 12px;
+ color: var(--text-3);
+}
diff --git a/demo/kanban-redesign/files.html b/demo/kanban-redesign/files.html
new file mode 100644
index 0000000000..46b19eb5ea
--- /dev/null
+++ b/demo/kanban-redesign/files.html
@@ -0,0 +1,235 @@
+
+
+
+
+
+文件 · 看板项目
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 名称类型更新时间大小
+
+
+
+
+ 设计稿
+
+
文件夹
+
07-24
+
—
+
+
+
+
+
+
+
+
+
+ 参考资料
+
+
文件夹
+
07-22
+
—
+
+
+
+
+
+
+
+
+
+ design-spec.pdf
+
+
PDF
+
07-24
+
2.4 MB
+
+
+
+
+
+
+
+
+
+ kanban-mockup.png
+
+
图片
+
07-23
+
1.1 MB
+
+
+
+
+
+
+
+
+
+ api-draft.yaml
+
+
YAML
+
07-21
+
18 KB
+
+
+
+
+
+
+
+
+
+ 需求说明.md
+
+
Markdown
+
07-20
+
6 KB
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 任务名称类型交付时间大小
+
+
+
A2C41F0-3
+
+
+ kanban-v1-final.png
+
+
图片
+
07-19 18:42
+
3.8 MB
+
+
+
A2C41F0-6
+
+
+ 权限模型说明.pdf
+
+
PDF
+
07-17 11:05
+
860 KB
+
+
+
1FFD6B9-1
+
+
+ ci-pipeline.yml
+
+
YAML
+
07-15 16:20
+
4 KB
+
+
+
+
+
+
+ 在 Wework 输入框中输入 @,即可让 AI 查看云项目、目录、任务或交付。
+
+
+
+
+
+
+
+
diff --git a/demo/kanban-redesign/index.html b/demo/kanban-redesign/index.html
new file mode 100644
index 0000000000..18894b698b
--- /dev/null
+++ b/demo/kanban-redesign/index.html
@@ -0,0 +1,508 @@
+
+
+
+
+
+看板 · 项目空间
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
1FFD6B9-2
+
整理项目需求文档
+
+ 普通
+ 07-24
+
+
+
+
+ 1 个子任务
+
+
+
+
+ 子任务
+
+
+
+
+
+
1FFD6B9-1
+
搭建 CI 流水线
+
+ 重要
+ 07-24
+
+
+
+
+
+
+
+
+
+
+
+
+
1FFD6B9-3
+
设计评审与走查
+
+ 紧急
+ 07-24
+
+
+
+
+ 2 个子任务
+
+
+
+
+ 子任务
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
设计评审与走查
+
+
+
+
+
描述
+
+
目标:对看板重设计稿进行完整走查,确认信息层级与交互状态。
+
覆盖事项看板、文件视图、任务详情三个核心页面;重点检查空状态、hover 反馈与徽章用色是否克制。
+
走查结论记录在本任务的评论中,问题项拆分为子任务跟进。
+
+
+
+
+
+ 子任务
+
2
+
+
+ 新建子任务
+
+
+
+
+ 1FFD6B9-4
+ 核对看板列的拖拽排序
+ 进行中
+
+
+
+ 1FFD6B9-5
+ 确认任务详情抽屉的字段完整性
+ 待开始
+
+
+
+
+
+
+
+
+
+
design-spec.pdf
+
+ 2.4 MB
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1FFD6B9-3
+ 设计评审与走查
+
+
+
+
+
+
+
+
+
+
新任务会获得当前项目空间上下文,可读取共享目录、任务和历史交付,但不会自动上传本地会话。
+
+
+
+
+
+
+
+
+
+
+
+
+
陈
+
+
+
+
+
+
林
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demo/kanban-redesign/my-work.html b/demo/kanban-redesign/my-work.html
new file mode 100644
index 0000000000..478fc742b0
--- /dev/null
+++ b/demo/kanban-redesign/my-work.html
@@ -0,0 +1,208 @@
+
+
+
+
+
+我的工作
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1FFD6B9-3
+ 设计评审与走查
+ 看板项目
+
+
+ A2C41F0-1
+ 梳理 V4 版本的里程碑拆分
+ Wegent V4
+
+
+ A2C41F0-7
+ 确认执行器镜像的发布清单
+ Wegent V4
+
+
+
+
+
+
+
+ 1FFD6B9-4
+ 核对看板列的拖拽排序
+ 看板项目
+
+
+ A2C41F0-2
+ 多页面静态 demo 搭建
+ Wegent V4
+
+
+
+
+
+
+
+ 1FFD6B9-2
+ 整理项目需求文档
+ 看板项目
+
+
+ A2C41F0-5
+ 共享文件权限模型评审
+ Wegent V4
+
+
+
+
+
+
+
+ A2C41F0-3
+ 看板页视觉方案初稿
+ Wegent V4
+
+
+
+
+
+
+
+
+
+
+
任务
+
项目
+
状态
+
优先级
+
截止日期
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demo/kanban-redesign/new-task.html b/demo/kanban-redesign/new-task.html
new file mode 100644
index 0000000000..a68b52afb0
--- /dev/null
+++ b/demo/kanban-redesign/new-task.html
@@ -0,0 +1,356 @@
+
+
+
+
+
+新建任务 · Notion 风格
+
+
+
+
+
+
+
+
+
+ 看板项目 · 新建任务
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
支持 Markdown,可拖拽文件到编辑器添加附件
+
+
+
+
+
+
+ 附件
+
+
+
+
+
design-spec.pdf
+
2.4 MB
+
+
+
+
+
kanban-mockup.png
+
1.1 MB
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demo/kanban-redesign/preview.png b/demo/kanban-redesign/preview.png
new file mode 100644
index 0000000000..8bad6ee7af
Binary files /dev/null and b/demo/kanban-redesign/preview.png differ
diff --git a/demo/kanban-redesign/projects.html b/demo/kanban-redesign/projects.html
new file mode 100644
index 0000000000..aed3cb37b0
--- /dev/null
+++ b/demo/kanban-redesign/projects.html
@@ -0,0 +1,161 @@
+
+
+
+
+
+项目空间
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
项目空间包含共享任务、文件与交付;成员可以关联各自不同的本地工作区。
+
+
+
+
项目标识将在创建时自动生成。
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demo/kanban-redesign/shots/files.png b/demo/kanban-redesign/shots/files.png
new file mode 100644
index 0000000000..f082010cab
Binary files /dev/null and b/demo/kanban-redesign/shots/files.png differ
diff --git a/demo/kanban-redesign/shots/index-detail.png b/demo/kanban-redesign/shots/index-detail.png
new file mode 100644
index 0000000000..07926f10e4
Binary files /dev/null and b/demo/kanban-redesign/shots/index-detail.png differ
diff --git a/demo/kanban-redesign/shots/index-members.png b/demo/kanban-redesign/shots/index-members.png
new file mode 100644
index 0000000000..23754f5fc9
Binary files /dev/null and b/demo/kanban-redesign/shots/index-members.png differ
diff --git a/demo/kanban-redesign/shots/index-new-task.png b/demo/kanban-redesign/shots/index-new-task.png
new file mode 100644
index 0000000000..e4225ab32b
Binary files /dev/null and b/demo/kanban-redesign/shots/index-new-task.png differ
diff --git a/demo/kanban-redesign/shots/index-start-task.png b/demo/kanban-redesign/shots/index-start-task.png
new file mode 100644
index 0000000000..31d6d76146
Binary files /dev/null and b/demo/kanban-redesign/shots/index-start-task.png differ
diff --git a/demo/kanban-redesign/shots/index.png b/demo/kanban-redesign/shots/index.png
new file mode 100644
index 0000000000..94db99b444
Binary files /dev/null and b/demo/kanban-redesign/shots/index.png differ
diff --git a/demo/kanban-redesign/shots/my-work.png b/demo/kanban-redesign/shots/my-work.png
new file mode 100644
index 0000000000..2f53edd8a3
Binary files /dev/null and b/demo/kanban-redesign/shots/my-work.png differ
diff --git a/demo/kanban-redesign/shots/new-task.png b/demo/kanban-redesign/shots/new-task.png
new file mode 100644
index 0000000000..a68ed68965
Binary files /dev/null and b/demo/kanban-redesign/shots/new-task.png differ
diff --git a/demo/kanban-redesign/shots/projects-new-project.png b/demo/kanban-redesign/shots/projects-new-project.png
new file mode 100644
index 0000000000..4f013b85d6
Binary files /dev/null and b/demo/kanban-redesign/shots/projects-new-project.png differ
diff --git a/demo/kanban-redesign/shots/projects.png b/demo/kanban-redesign/shots/projects.png
new file mode 100644
index 0000000000..5e7eaa4365
Binary files /dev/null and b/demo/kanban-redesign/shots/projects.png differ
diff --git a/demo/kanban-redesign/shots/verify/my-work-calendar.png b/demo/kanban-redesign/shots/verify/my-work-calendar.png
new file mode 100644
index 0000000000..84e990b4c9
Binary files /dev/null and b/demo/kanban-redesign/shots/verify/my-work-calendar.png differ
diff --git a/demo/kanban-redesign/shots/verify/my-work-group.png b/demo/kanban-redesign/shots/verify/my-work-group.png
new file mode 100644
index 0000000000..2c24239ae5
Binary files /dev/null and b/demo/kanban-redesign/shots/verify/my-work-group.png differ
diff --git a/demo/kanban-redesign/shots/verify/my-work-list.png b/demo/kanban-redesign/shots/verify/my-work-list.png
new file mode 100644
index 0000000000..f0502a3c49
Binary files /dev/null and b/demo/kanban-redesign/shots/verify/my-work-list.png differ
diff --git a/demo/kanban-redesign/shots/verify/my-work-timeline.png b/demo/kanban-redesign/shots/verify/my-work-timeline.png
new file mode 100644
index 0000000000..f76118b3b9
Binary files /dev/null and b/demo/kanban-redesign/shots/verify/my-work-timeline.png differ
diff --git a/demo/kanban-redesign/shots/verify/shot-mywork.js b/demo/kanban-redesign/shots/verify/shot-mywork.js
new file mode 100644
index 0000000000..92e3564bd6
--- /dev/null
+++ b/demo/kanban-redesign/shots/verify/shot-mywork.js
@@ -0,0 +1,15 @@
+const { chromium } = require('playwright-core');
+(async () => {
+ const browser = await chromium.launch();
+ const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+ const base = 'file:///Volumes/OuterHD/OuterIdeaProjects/weibo_wegent/github_wegent/demo/kanban-redesign/my-work.html';
+ const out = '/Volumes/OuterHD/OuterIdeaProjects/weibo_wegent/github_wegent/demo/kanban-redesign/shots/verify/';
+ for (const view of ['group', 'list', 'calendar', 'timeline']) {
+ await page.goto(base);
+ await page.waitForTimeout(600);
+ await page.click(`[data-switch="${view}"]`);
+ await page.waitForTimeout(500);
+ await page.screenshot({ path: out + 'my-work-' + view + '.png' });
+ }
+ await browser.close();
+})();
diff --git a/executor/src/agents/codex.rs b/executor/src/agents/codex.rs
index fffb29cf32..c736736d3b 100644
--- a/executor/src/agents/codex.rs
+++ b/executor/src/agents/codex.rs
@@ -705,6 +705,10 @@ fn persistent_codex_app_server_launch_config(
launch_config.config_overrides.extend([
"goals=true".to_owned(),
"features.code_mode_host=true".to_owned(),
+ // MCP tools are deferred behind tool_search by the bundled Codex. The
+ // search tool must be enabled at persistent app-server startup; enabling
+ // it per thread is too late because feature registration is process-wide.
+ "features.tool_search=true".to_owned(),
]);
launch_config
.config_overrides
diff --git a/executor/src/agents/codex/tests.rs b/executor/src/agents/codex/tests.rs
index c2574e0b13..523fe6955d 100644
--- a/executor/src/agents/codex/tests.rs
+++ b/executor/src/agents/codex/tests.rs
@@ -105,6 +105,20 @@ fn streaming_patch_overrides_enable_freeform_apply_patch() {
assert!(overrides.contains(&"suppress_unstable_features_warning=true".to_owned()));
}
+#[test]
+fn persistent_app_server_enables_deferred_mcp_tool_search() {
+ let request_config = CodexLaunchConfig::default();
+
+ let config = persistent_codex_app_server_launch_config(&request_config);
+
+ assert!(config
+ .config_overrides
+ .contains(&"features.tool_search=true".to_owned()));
+ assert!(!config
+ .config_overrides
+ .contains(&"features.tool_search_always_defer_mcp_tools=false".to_owned()));
+}
+
#[test]
fn wework_codex_home_defaults_to_executor_home_codex() {
let _lock = crate::test_env::lock();
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c37d1393b4..079d26dc45 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -472,6 +472,15 @@ importers:
wework:
dependencies:
+ '@blocknote/core':
+ specifier: 0.52.1
+ version: 0.52.1(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@types/hast@3.0.5)(highlight.js@11.11.1)(refractor@5.0.0)
+ '@blocknote/mantine':
+ specifier: 0.52.1
+ version: 0.52.1(@floating-ui/dom@1.8.0)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@mantine/core@9.4.2(@mantine/hooks@9.4.2(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.2(react@19.2.7))(@types/hast@3.0.5)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(highlight.js@11.11.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(refractor@5.0.0)
+ '@blocknote/react':
+ specifier: 0.52.1
+ version: 0.52.1(@floating-ui/dom@1.8.0)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@types/hast@3.0.5)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(highlight.js@11.11.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(refractor@5.0.0)
'@chenglou/pretext':
specifier: 0.0.8
version: 0.0.8
@@ -532,6 +541,15 @@ importers:
'@file-viewer/react':
specifier: 2.1.26
version: 2.1.26(react@19.2.7)
+ '@fullcalendar/core':
+ specifier: ^6.1.21
+ version: 6.1.21
+ '@fullcalendar/daygrid':
+ specifier: ^6.1.21
+ version: 6.1.21(@fullcalendar/core@6.1.21)
+ '@fullcalendar/react':
+ specifier: ^6.1.21
+ version: 6.1.21(@fullcalendar/core@6.1.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@headless-tree/core':
specifier: ^1.7.0
version: 1.7.0
@@ -577,6 +595,9 @@ importers:
'@tauri-apps/plugin-updater':
specifier: ^2.10.1
version: 2.10.1
+ '@tiptap/core':
+ specifier: 2.27.2
+ version: 2.27.2(@tiptap/pm@2.27.2)
'@tiptap/extension-link':
specifier: ^2.27.2
version: 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)
@@ -589,6 +610,9 @@ importers:
'@tiptap/extension-task-list':
specifier: ^2.27.2
version: 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))
+ '@tiptap/pm':
+ specifier: 2.27.2
+ version: 2.27.2
'@tiptap/react':
specifier: ^2.27.2
version: 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -954,6 +978,43 @@ packages:
'@bcoe/v8-coverage@0.2.3':
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
+ '@blocknote/core@0.52.1':
+ resolution: {integrity: sha512-a4MgyzS+1Xc6ikEiUgaK1hxUlW9aJFKfyJVQ89OL5kbcnJIRRhIDTIr5ffY7f22wFMT6gjq/Aj+omNof8C1j/A==}
+ peerDependencies:
+ '@y/prosemirror': ^2.0.0-6
+ '@y/protocols': ^1.0.6-rc.1
+ '@y/y': ^14.0.0-rc.23
+ y-prosemirror: ^1.3.7
+ y-protocols: ^1.0.6
+ yjs: ^13.6.27
+ peerDependenciesMeta:
+ '@y/prosemirror':
+ optional: true
+ '@y/protocols':
+ optional: true
+ '@y/y':
+ optional: true
+ y-prosemirror:
+ optional: true
+ y-protocols:
+ optional: true
+ yjs:
+ optional: true
+
+ '@blocknote/mantine@0.52.1':
+ resolution: {integrity: sha512-evoqFD41Vk0uCgT3CE4Z/7nrlmg6Op3sRtnF9CffiHrsfb3AYVzGlmRv5f2SCKcYwPzBXkLNJMYHZ+tI0ol22g==}
+ peerDependencies:
+ '@mantine/core': ^8.3.11 || ^9.0.2
+ '@mantine/hooks': ^8.3.11 || ^9.0.2
+ react: ^18.0 || ^19.0 || >= 19.0.0-rc
+ react-dom: ^18.0 || ^19.0 || >= 19.0.0-rc
+
+ '@blocknote/react@0.52.1':
+ resolution: {integrity: sha512-uBSVInj+T6bewINaRZjs6Nad6uL8t++vRLSWgj4cExpL6LuJRM5CPMTzm26eBjgEbC4yO4cSYowsVYh6efgbfw==}
+ peerDependencies:
+ react: ^18.0 || ^19.0 || >= 19.0.0-rc
+ react-dom: ^18.0 || ^19.0 || >= 19.0.0-rc
+
'@braintree/sanitize-url@7.1.2':
resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==}
@@ -1152,6 +1213,9 @@ packages:
'@emnapi/wasi-threads@1.2.1':
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
+ '@emoji-mart/data@1.2.1':
+ resolution: {integrity: sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==}
+
'@emotion/babel-plugin@11.13.5':
resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==}
@@ -1554,24 +1618,45 @@ packages:
'@floating-ui/core@1.7.5':
resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
+ '@floating-ui/core@1.8.0':
+ resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
+
'@floating-ui/dom@1.7.6':
resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==}
+ '@floating-ui/dom@1.8.0':
+ resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==}
+
'@floating-ui/react-dom@2.1.8':
resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
+ '@floating-ui/react-dom@2.1.9':
+ resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
'@floating-ui/react@0.26.28':
resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
+ '@floating-ui/react@0.27.20':
+ resolution: {integrity: sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw==}
+ peerDependencies:
+ react: '>=17.0.0'
+ react-dom: '>=17.0.0'
+
'@floating-ui/utils@0.2.11':
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
+ '@floating-ui/utils@0.2.12':
+ resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
+
'@flyfish-dev/cad-viewer@0.6.4':
resolution: {integrity: sha512-9GiILTzBfkyACcd65rSYiN9CMhoXzn1Bh9pvwaSMcWAHCkJoAx/xN1wP3M+z2KFtFVGqfipA1SmnPRVZIXaAow==}
engines: {node: '>=20.19.0'}
@@ -1579,6 +1664,28 @@ packages:
'@fontsource-variable/noto-sans-sc@5.2.10':
resolution: {integrity: sha512-zdk10i5HrDQTXI7ldD61zToX1fsgig8vDTsu7zB48SXOitWfuX0e5viZAwnkHuhwh096PU6X6i1AyAsbBCISpA==}
+ '@fullcalendar/core@6.1.21':
+ resolution: {integrity: sha512-t3u/+sqh3Iq7TWtUnVLcGDUE6OWZh0UD3c04bI/l7lSLAgAKr3kngBmhHiQD1QXpwC8ZN5iNqG7a7gOVixhSKQ==}
+
+ '@fullcalendar/daygrid@6.1.21':
+ resolution: {integrity: sha512-QYb1y40RGYLlOxKpYWg8O+7njEnKnFG8Tt7qjnubJGR35s1phQg67E+81y2TyAbbm59p2JFOCXGDk9t6KDujIA==}
+ peerDependencies:
+ '@fullcalendar/core': ~6.1.21
+
+ '@fullcalendar/react@6.1.21':
+ resolution: {integrity: sha512-TLpmGUd5k/PMdCh8XbeFC9PW9wuGvMms1oCxWgXyjK3EFPXAAd0PLfcvwKdyxoAS5eK1E4RJFkjMHvsYHpimcg==}
+ peerDependencies:
+ '@fullcalendar/core': ~6.1.21
+ react: ^16.7.0 || ^17 || ^18 || ^19
+ react-dom: ^16.7.0 || ^17 || ^18 || ^19
+
+ '@handlewithcare/prosemirror-inputrules@0.1.4':
+ resolution: {integrity: sha512-GMqlBeG2MKM+tXEFd2N+wIv5z4VvJTg8JtfJUrdjvFq2W6v+AW8oTgiWyFw8L3iEQwvtQcVJxU873iB0LXUNNw==}
+ peerDependencies:
+ prosemirror-model: ^1.0.0
+ prosemirror-state: ^1.0.0
+ prosemirror-view: ^1.0.0
+
'@headless-tree/core@1.7.0':
resolution: {integrity: sha512-LxcX7LNepwfOPrZcs4PNfDwCzbi326uCAX5jYBfw94jI+pZQA7ANaE/No3LW0XFgcBcQtK/G2bInbVEhLF1C/Q==}
@@ -1983,6 +2090,18 @@ packages:
resolution: {integrity: sha512-cAfbd8ANkOmyHgE3FZrCJwdyQv8ty0woMC6toSUvgo6Cm2crS3ibS+YKrTPDbDhjP6cJoVsoboRNdCWeDL5xMg==}
engines: {node: '>=14.8.0'}
+ '@mantine/core@9.4.2':
+ resolution: {integrity: sha512-bEcFalEvbcT8x03GF0eUeWVLF8lGADdfG8jjrPfn+JdK4Sd33ZFdvTZxY/hhkzRi/JsMdhZj8GU0cGkXc0yPzg==}
+ peerDependencies:
+ '@mantine/hooks': 9.4.2
+ react: ^19.2.0
+ react-dom: ^19.2.0
+
+ '@mantine/hooks@9.4.2':
+ resolution: {integrity: sha512-1SUuhnbvcV7EfwYN2FcdI9nX2TXhQQMefR7uWsigdor+2Twk0yEKMKct8t9gw6PQtkWF9GZxkg8aJRA3KmhaSA==}
+ peerDependencies:
+ react: ^19.2.0
+
'@mapbox/jsonlint-lines-primitives@2.0.3':
resolution: {integrity: sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==}
engines: {node: '>= 22'}
@@ -3182,6 +3301,12 @@ packages:
peerDependencies:
tailwindcss: '>=3.0.0 || >=4.0.0 || insiders'
+ '@tanstack/react-store@0.7.7':
+ resolution: {integrity: sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
'@tanstack/react-table@8.21.3':
resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
engines: {node: '>=12'}
@@ -3195,6 +3320,9 @@ packages:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ '@tanstack/store@0.7.7':
+ resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==}
+
'@tanstack/table-core@8.21.3':
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
@@ -3338,6 +3466,11 @@ packages:
peerDependencies:
'@tiptap/pm': ^2.7.0
+ '@tiptap/core@3.28.0':
+ resolution: {integrity: sha512-gUuD5WAYfbDxNSSJya/emh2KSzXZXLUYKW4fEnc1AQ5FE2twzh4LJ9UlKFIawigrUCAksWI5Fy1hRbv+5m4ZdQ==}
+ peerDependencies:
+ '@tiptap/pm': 3.28.0
+
'@tiptap/extension-blockquote@2.27.2':
resolution: {integrity: sha512-oIGZgiAeA4tG3YxbTDfrmENL4/CIwGuP3THtHsNhwRqwsl9SfMk58Ucopi2GXTQSdYXpRJ0ahE6nPqB5D6j/Zw==}
peerDependencies:
@@ -3348,12 +3481,23 @@ packages:
peerDependencies:
'@tiptap/core': ^2.7.0
+ '@tiptap/extension-bold@3.28.0':
+ resolution: {integrity: sha512-JhZQmr0AU741bOwjVMfuwJdK4g0TQwwPbeca9aqKHv5zvZw4i4G9G6fESVyMFc3Yag1ffpnq5EtNldHKTnMhlw==}
+ peerDependencies:
+ '@tiptap/core': 3.28.0
+
'@tiptap/extension-bubble-menu@2.27.2':
resolution: {integrity: sha512-VkwlCOcr0abTBGzjPXklJ92FCowG7InU8+Od9FyApdLNmn0utRYGRhw0Zno6VgE9EYr1JY4BRnuSa5f9wlR72w==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
+ '@tiptap/extension-bubble-menu@3.28.0':
+ resolution: {integrity: sha512-7AUNoHj2K4XLKCgW4uspeD/ENejPu2BeHvLTsiOoIO+XXDNSi2j0bbaIS8f2s/qnFirscwp/sbznp1qph/76qg==}
+ peerDependencies:
+ '@tiptap/core': 3.28.0
+ '@tiptap/pm': 3.28.0
+
'@tiptap/extension-bullet-list@2.27.2':
resolution: {integrity: sha512-gmFuKi97u5f8uFc/GQs+zmezjiulZmFiDYTh3trVoLRoc2SAHOjGEB7qxdx7dsqmMN7gwiAWAEVurLKIi1lnnw==}
peerDependencies:
@@ -3370,6 +3514,11 @@ packages:
peerDependencies:
'@tiptap/core': ^2.7.0
+ '@tiptap/extension-code@3.28.0':
+ resolution: {integrity: sha512-AUw2Acof3CQE6Q6Y22saK4lyg0nkeJ4XXniU65TdAi/9TE66TGiO4NQJJNNPgu8+VMEwl5j8VsIFK8sfTcNYtg==}
+ peerDependencies:
+ '@tiptap/core': 3.28.0
+
'@tiptap/extension-document@2.27.2':
resolution: {integrity: sha512-CFhAYsPnyYnosDC4639sCJnBUnYH4Cat9qH5NZWHVvdgtDwu8GZgZn2eSzaKSYXWH1vJ9DSlCK+7UyC3SNXIBA==}
peerDependencies:
@@ -3387,6 +3536,13 @@ packages:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
+ '@tiptap/extension-floating-menu@3.28.0':
+ resolution: {integrity: sha512-59SECvJq3pQfeJBuydEdhQqrpl0oDlk8N1Ovs1Si3/fJa6rEQAJB2zIvcpBHky9Z+5JodI2eueDnv8eimiyGbg==}
+ peerDependencies:
+ '@floating-ui/dom': ^1.0.0
+ '@tiptap/core': 3.28.0
+ '@tiptap/pm': 3.28.0
+
'@tiptap/extension-gapcursor@2.27.2':
resolution: {integrity: sha512-/c9VF1HBxj+AP54XGVgCmD9bEGYc5w5OofYCFQgM7l7PB1J00A4vOke0oPkHJnqnOOyPlFaxO/7N6l3XwFcnKA==}
peerDependencies:
@@ -3420,6 +3576,11 @@ packages:
peerDependencies:
'@tiptap/core': ^2.7.0
+ '@tiptap/extension-italic@3.28.0':
+ resolution: {integrity: sha512-Yur/ELz6dNVKQC7m8wGjtoLFxamGjJmA0rUEEOacTH6J39aFmcSxYkEGNDkYHbZFyHFYqbej6eI3VE0h08O0mg==}
+ peerDependencies:
+ '@tiptap/core': 3.28.0
+
'@tiptap/extension-link@2.27.2':
resolution: {integrity: sha512-bnP61qkr0Kj9Cgnop1hxn2zbOCBzNtmawxr92bVTOE31fJv6FhtCnQiD6tuPQVGMYhcmAj7eihtvuEMFfqEPcQ==}
peerDependencies:
@@ -3452,6 +3613,11 @@ packages:
peerDependencies:
'@tiptap/core': ^2.7.0
+ '@tiptap/extension-strike@3.28.0':
+ resolution: {integrity: sha512-VVj2ZZU9QYqiHLcjqMqRYvuHSsokj/AgUl+6TzLrKjlWwyZ18D4H2vkyI0g70iVTKnUpZ3sEy8WA/rqjgBjqBg==}
+ peerDependencies:
+ '@tiptap/core': 3.28.0
+
'@tiptap/extension-task-item@2.27.2':
resolution: {integrity: sha512-ZBSqj/dygB/Rp5K9qOxRVwASTZCmKVoTq8C59KvMgD/aFjJxhq/w2dZaWkCUEXEep+NmvJqo0kfeAEMY5UDnGg==}
peerDependencies:
@@ -3473,9 +3639,28 @@ packages:
peerDependencies:
'@tiptap/core': ^2.7.0
+ '@tiptap/extension-text@3.28.0':
+ resolution: {integrity: sha512-Jqp1LgfY1mnp4TQHoy+vnHyfn6qnnAM6nHgGwbY5zA8b/Xf1Etll6pziTx9p1J1qcfAgSrnjOyAmA++H7VQqww==}
+ peerDependencies:
+ '@tiptap/core': 3.28.0
+
+ '@tiptap/extension-underline@3.28.0':
+ resolution: {integrity: sha512-rwxCS6vTh2DJkNIYQX7JSrrRSmm76e2y48aZeuZO5ShkvLWMuJ3/zqDHRxAQVDiJhuE2Cp8NrTmPYlUc9YrT0A==}
+ peerDependencies:
+ '@tiptap/core': 3.28.0
+
+ '@tiptap/extensions@3.28.0':
+ resolution: {integrity: sha512-DJT1khCK+O/pT1gQlAnoKAx6zwDkgv7GtnhSfkqm1/4KHC3x+SSJnBIgBl9oGHymA+DxWbW1EULU4V3d/kT2uQ==}
+ peerDependencies:
+ '@tiptap/core': 3.28.0
+ '@tiptap/pm': 3.28.0
+
'@tiptap/pm@2.27.2':
resolution: {integrity: sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==}
+ '@tiptap/pm@3.28.0':
+ resolution: {integrity: sha512-ALcpwZMUdat9gjJKlpscpoqXStoLhU246LPEVBDvJdIsoUKvUu3MrzfXik2Y8mtSGfhjtm9O2TRkWxQiFVMwsQ==}
+
'@tiptap/react@2.27.2':
resolution: {integrity: sha512-0EAs8Cpkfbvben1PZ34JN2Nd79Dhioynm2jML27DBbf1VWPk+FFWFGTMLUT0bu+Np5iVxio8fqV9t0mc4D6thA==}
peerDependencies:
@@ -3484,6 +3669,16 @@ packages:
react: ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
+ '@tiptap/react@3.28.0':
+ resolution: {integrity: sha512-BxzSAqaDEldQ97K/v6+GizU1/Dxx9VWkRh7PLQql6bXlAMiz6T1G3dGt25vvv1hQ1FoMt5ExWY5NkrPmR8G8ew==}
+ peerDependencies:
+ '@tiptap/core': 3.28.0
+ '@tiptap/pm': 3.28.0
+ '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
+ '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0
+ react: ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
+
'@tiptap/starter-kit@2.27.2':
resolution: {integrity: sha512-bb0gJvPoDuyRUQ/iuN52j1//EtWWttw+RXAv1uJxfR0uKf8X7uAqzaOOgwjknoCIDC97+1YHwpGdnRjpDkOBxw==}
@@ -3758,6 +3953,9 @@ packages:
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
+ '@types/use-sync-external-store@1.5.0':
+ resolution: {integrity: sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==}
+
'@types/yargs-parser@21.0.3':
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
@@ -4994,6 +5192,9 @@ packages:
resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==}
engines: {node: '>=12'}
+ emoji-mart@5.6.0:
+ resolution: {integrity: sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==}
+
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
@@ -5285,6 +5486,10 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+ fast-equals@5.4.1:
+ resolution: {integrity: sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==}
+ engines: {node: '>=6.0.0'}
+
fast-glob@3.3.1:
resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
engines: {node: '>=8.6.0'}
@@ -6256,6 +6461,11 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
+ lib0@1.0.0-rc.22:
+ resolution: {integrity: sha512-KNefJloRQIsWncTF2tIcRqQXSQ7bDRYHwVSUhf1lY2P65Rej4WWFnen6L8L+odJQIo1ZNJGVVjK2WzqB9a+B/g==}
+ engines: {node: '>=22'}
+ hasBin: true
+
libarchive.js@2.0.2:
resolution: {integrity: sha512-JHb+P4suNSjvz/dMdRgOe7JAxluXJeialzSFkKHU5y0ZK+m175drPOaIYW6I9WXSDcPcQ13eCUgMnpgY0ggmoQ==}
@@ -7100,6 +7310,9 @@ packages:
peerDependencies:
preact: '>=10 || >= 11.0.0-0'
+ preact@10.12.1:
+ resolution: {integrity: sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==}
+
preact@11.0.0-beta.0:
resolution: {integrity: sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg==}
@@ -7174,6 +7387,47 @@ packages:
prosemirror-gapcursor@1.4.1:
resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==}
+ prosemirror-highlight@0.15.3:
+ resolution: {integrity: sha512-WVV2st0fX1w2TkAgmTmdbj77BlWYuLfW4BGXPo8JfIWsSly5xYcfID8QJQL+GT8kisMRuu4jgT6JAqj1DOwvvg==}
+ peerDependencies:
+ '@lezer/common': ^1.0.0
+ '@lezer/highlight': ^1.0.0
+ '@shikijs/types': ^1.29.2 || ^2.0.0 || ^3.0.0 || ^4.0.0
+ '@types/hast': ^3.0.0
+ highlight.js: ^11.9.0
+ lowlight: ^3.1.0
+ prosemirror-model: ^1.19.3
+ prosemirror-state: ^1.4.3
+ prosemirror-transform: ^1.8.0
+ prosemirror-view: ^1.32.4
+ refractor: ^5.0.0
+ sugar-high: ^0.6.1 || ^0.7.0 || ^0.8.0 || ^0.9.0 || ^1.0.0
+ peerDependenciesMeta:
+ '@lezer/common':
+ optional: true
+ '@lezer/highlight':
+ optional: true
+ '@shikijs/types':
+ optional: true
+ '@types/hast':
+ optional: true
+ highlight.js:
+ optional: true
+ lowlight:
+ optional: true
+ prosemirror-model:
+ optional: true
+ prosemirror-state:
+ optional: true
+ prosemirror-transform:
+ optional: true
+ prosemirror-view:
+ optional: true
+ refractor:
+ optional: true
+ sugar-high:
+ optional: true
+
prosemirror-history@1.5.0:
resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==}
@@ -7332,6 +7586,12 @@ packages:
'@types/react': '>=18'
react: '>=18'
+ react-number-format@5.4.5:
+ resolution: {integrity: sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==}
+ peerDependencies:
+ react: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
react-remove-scroll-bar@2.3.8:
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
engines: {node: '>=10'}
@@ -8823,6 +9083,99 @@ snapshots:
'@bcoe/v8-coverage@0.2.3': {}
+ '@blocknote/core@0.52.1(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@types/hast@3.0.5)(highlight.js@11.11.1)(refractor@5.0.0)':
+ dependencies:
+ '@emoji-mart/data': 1.2.1
+ '@handlewithcare/prosemirror-inputrules': 0.1.4(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)
+ '@shikijs/types': 4.2.0
+ '@tanstack/store': 0.7.7
+ '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0)
+ '@tiptap/extension-bold': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))
+ '@tiptap/extension-code': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))
+ '@tiptap/extension-italic': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))
+ '@tiptap/extension-strike': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))
+ '@tiptap/extension-text': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))
+ '@tiptap/extension-underline': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))
+ '@tiptap/extensions': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)
+ '@tiptap/pm': 3.28.0
+ emoji-mart: 5.6.0
+ fast-deep-equal: 3.1.3
+ lib0: 1.0.0-rc.22
+ prosemirror-highlight: 0.15.3(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.2.0)(@types/hast@3.0.5)(highlight.js@11.11.1)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.0)(refractor@5.0.0)
+ prosemirror-model: 1.25.10
+ prosemirror-state: 1.4.4
+ prosemirror-tables: 1.8.5
+ prosemirror-transform: 1.12.0
+ prosemirror-view: 1.42.0
+ transitivePeerDependencies:
+ - '@lezer/common'
+ - '@lezer/highlight'
+ - '@types/hast'
+ - highlight.js
+ - lowlight
+ - refractor
+ - sugar-high
+
+ '@blocknote/mantine@0.52.1(@floating-ui/dom@1.8.0)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@mantine/core@9.4.2(@mantine/hooks@9.4.2(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.2(react@19.2.7))(@types/hast@3.0.5)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(highlight.js@11.11.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(refractor@5.0.0)':
+ dependencies:
+ '@blocknote/core': 0.52.1(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@types/hast@3.0.5)(highlight.js@11.11.1)(refractor@5.0.0)
+ '@blocknote/react': 0.52.1(@floating-ui/dom@1.8.0)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@types/hast@3.0.5)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(highlight.js@11.11.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(refractor@5.0.0)
+ '@mantine/core': 9.4.2(@mantine/hooks@9.4.2(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@mantine/hooks': 9.4.2(react@19.2.7)
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+ transitivePeerDependencies:
+ - '@floating-ui/dom'
+ - '@lezer/common'
+ - '@lezer/highlight'
+ - '@types/hast'
+ - '@types/react'
+ - '@types/react-dom'
+ - '@y/prosemirror'
+ - '@y/protocols'
+ - '@y/y'
+ - highlight.js
+ - lowlight
+ - refractor
+ - sugar-high
+ - y-prosemirror
+ - y-protocols
+ - yjs
+
+ '@blocknote/react@0.52.1(@floating-ui/dom@1.8.0)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@types/hast@3.0.5)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(highlight.js@11.11.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(refractor@5.0.0)':
+ dependencies:
+ '@blocknote/core': 0.52.1(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@types/hast@3.0.5)(highlight.js@11.11.1)(refractor@5.0.0)
+ '@emoji-mart/data': 1.2.1
+ '@floating-ui/react': 0.27.20(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@tanstack/react-store': 0.7.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0)
+ '@tiptap/pm': 3.28.0
+ '@tiptap/react': 3.28.0(@floating-ui/dom@1.8.0)(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ emoji-mart: 5.6.0
+ fast-deep-equal: 3.1.3
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+ use-sync-external-store: 1.6.0(react@19.2.7)
+ optionalDependencies:
+ '@types/use-sync-external-store': 1.5.0
+ transitivePeerDependencies:
+ - '@floating-ui/dom'
+ - '@lezer/common'
+ - '@lezer/highlight'
+ - '@types/hast'
+ - '@types/react'
+ - '@types/react-dom'
+ - '@y/prosemirror'
+ - '@y/protocols'
+ - '@y/y'
+ - highlight.js
+ - lowlight
+ - refractor
+ - sugar-high
+ - y-prosemirror
+ - y-protocols
+ - yjs
+
'@braintree/sanitize-url@7.1.2': {}
'@bramus/specificity@2.4.2':
@@ -9184,6 +9537,8 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@emoji-mart/data@1.2.1': {}
+
'@emotion/babel-plugin@11.13.5':
dependencies:
'@babel/helper-module-imports': 7.29.7
@@ -9665,17 +10020,32 @@ snapshots:
dependencies:
'@floating-ui/utils': 0.2.11
+ '@floating-ui/core@1.8.0':
+ dependencies:
+ '@floating-ui/utils': 0.2.12
+
'@floating-ui/dom@1.7.6':
dependencies:
'@floating-ui/core': 1.7.5
'@floating-ui/utils': 0.2.11
+ '@floating-ui/dom@1.8.0':
+ dependencies:
+ '@floating-ui/core': 1.8.0
+ '@floating-ui/utils': 0.2.12
+
'@floating-ui/react-dom@2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@floating-ui/dom': 1.7.6
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
+ '@floating-ui/react-dom@2.1.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
+ dependencies:
+ '@floating-ui/dom': 1.8.0
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+
'@floating-ui/react@0.26.28(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -9684,8 +10054,18 @@ snapshots:
react-dom: 19.2.7(react@19.2.7)
tabbable: 6.4.0
+ '@floating-ui/react@0.27.20(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
+ dependencies:
+ '@floating-ui/react-dom': 2.1.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@floating-ui/utils': 0.2.12
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+ tabbable: 6.4.0
+
'@floating-ui/utils@0.2.11': {}
+ '@floating-ui/utils@0.2.12': {}
+
'@flyfish-dev/cad-viewer@0.6.4(three@0.184.0)':
dependencies:
'@mlightcad/libredwg-web': 0.7.7
@@ -9696,6 +10076,28 @@ snapshots:
'@fontsource-variable/noto-sans-sc@5.2.10': {}
+ '@fullcalendar/core@6.1.21':
+ dependencies:
+ preact: 10.12.1
+
+ '@fullcalendar/daygrid@6.1.21(@fullcalendar/core@6.1.21)':
+ dependencies:
+ '@fullcalendar/core': 6.1.21
+
+ '@fullcalendar/react@6.1.21(@fullcalendar/core@6.1.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
+ dependencies:
+ '@fullcalendar/core': 6.1.21
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+
+ '@handlewithcare/prosemirror-inputrules@0.1.4(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)':
+ dependencies:
+ prosemirror-history: 1.5.0
+ prosemirror-model: 1.25.10
+ prosemirror-state: 1.4.4
+ prosemirror-transform: 1.12.0
+ prosemirror-view: 1.42.0
+
'@headless-tree/core@1.7.0': {}
'@headless-tree/react@1.7.0(@headless-tree/core@1.7.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
@@ -10207,6 +10609,23 @@ snapshots:
dependencies:
'@xmldom/xmldom': 0.9.10
+ '@mantine/core@9.4.2(@mantine/hooks@9.4.2(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
+ dependencies:
+ '@floating-ui/react': 0.27.20(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@mantine/hooks': 9.4.2(react@19.2.7)
+ clsx: 2.1.1
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+ react-number-format: 5.4.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7)
+ type-fest: 5.7.0
+ transitivePeerDependencies:
+ - '@types/react'
+
+ '@mantine/hooks@9.4.2(react@19.2.7)':
+ dependencies:
+ react: 19.2.7
+
'@mapbox/jsonlint-lines-primitives@2.0.3': {}
'@mapbox/point-geometry@1.1.0': {}
@@ -11355,6 +11774,13 @@ snapshots:
postcss-selector-parser: 6.0.10
tailwindcss: 3.4.19(tsx@4.22.4)(yaml@2.9.0)
+ '@tanstack/react-store@0.7.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
+ dependencies:
+ '@tanstack/store': 0.7.7
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+ use-sync-external-store: 1.6.0(react@19.2.7)
+
'@tanstack/react-table@8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@tanstack/table-core': 8.21.3
@@ -11367,6 +11793,8 @@ snapshots:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
+ '@tanstack/store@0.7.7': {}
+
'@tanstack/table-core@8.21.3': {}
'@tanstack/virtual-core@3.17.0': {}
@@ -11487,6 +11915,10 @@ snapshots:
dependencies:
'@tiptap/pm': 2.27.2
+ '@tiptap/core@3.28.0(@tiptap/pm@3.28.0)':
+ dependencies:
+ '@tiptap/pm': 3.28.0
+
'@tiptap/extension-blockquote@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))':
dependencies:
'@tiptap/core': 2.27.2(@tiptap/pm@2.27.2)
@@ -11495,12 +11927,23 @@ snapshots:
dependencies:
'@tiptap/core': 2.27.2(@tiptap/pm@2.27.2)
+ '@tiptap/extension-bold@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))':
+ dependencies:
+ '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0)
+
'@tiptap/extension-bubble-menu@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)':
dependencies:
'@tiptap/core': 2.27.2(@tiptap/pm@2.27.2)
'@tiptap/pm': 2.27.2
tippy.js: 6.3.7
+ '@tiptap/extension-bubble-menu@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)':
+ dependencies:
+ '@floating-ui/dom': 1.7.6
+ '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0)
+ '@tiptap/pm': 3.28.0
+ optional: true
+
'@tiptap/extension-bullet-list@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))':
dependencies:
'@tiptap/core': 2.27.2(@tiptap/pm@2.27.2)
@@ -11514,6 +11957,10 @@ snapshots:
dependencies:
'@tiptap/core': 2.27.2(@tiptap/pm@2.27.2)
+ '@tiptap/extension-code@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))':
+ dependencies:
+ '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0)
+
'@tiptap/extension-document@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))':
dependencies:
'@tiptap/core': 2.27.2(@tiptap/pm@2.27.2)
@@ -11529,6 +11976,13 @@ snapshots:
'@tiptap/pm': 2.27.2
tippy.js: 6.3.7
+ '@tiptap/extension-floating-menu@3.28.0(@floating-ui/dom@1.8.0)(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)':
+ dependencies:
+ '@floating-ui/dom': 1.8.0
+ '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0)
+ '@tiptap/pm': 3.28.0
+ optional: true
+
'@tiptap/extension-gapcursor@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)':
dependencies:
'@tiptap/core': 2.27.2(@tiptap/pm@2.27.2)
@@ -11556,6 +12010,10 @@ snapshots:
dependencies:
'@tiptap/core': 2.27.2(@tiptap/pm@2.27.2)
+ '@tiptap/extension-italic@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))':
+ dependencies:
+ '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0)
+
'@tiptap/extension-link@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)':
dependencies:
'@tiptap/core': 2.27.2(@tiptap/pm@2.27.2)
@@ -11583,6 +12041,10 @@ snapshots:
dependencies:
'@tiptap/core': 2.27.2(@tiptap/pm@2.27.2)
+ '@tiptap/extension-strike@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))':
+ dependencies:
+ '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0)
+
'@tiptap/extension-task-item@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)':
dependencies:
'@tiptap/core': 2.27.2(@tiptap/pm@2.27.2)
@@ -11600,6 +12062,19 @@ snapshots:
dependencies:
'@tiptap/core': 2.27.2(@tiptap/pm@2.27.2)
+ '@tiptap/extension-text@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))':
+ dependencies:
+ '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0)
+
+ '@tiptap/extension-underline@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))':
+ dependencies:
+ '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0)
+
+ '@tiptap/extensions@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)':
+ dependencies:
+ '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0)
+ '@tiptap/pm': 3.28.0
+
'@tiptap/pm@2.27.2':
dependencies:
prosemirror-changeset: 2.4.1
@@ -11621,6 +12096,22 @@ snapshots:
prosemirror-transform: 1.12.0
prosemirror-view: 1.42.0
+ '@tiptap/pm@3.28.0':
+ dependencies:
+ prosemirror-changeset: 2.4.1
+ prosemirror-commands: 1.7.1
+ prosemirror-dropcursor: 1.8.3
+ prosemirror-gapcursor: 1.4.1
+ prosemirror-history: 1.5.0
+ prosemirror-inputrules: 1.5.1
+ prosemirror-keymap: 1.2.3
+ prosemirror-model: 1.25.10
+ prosemirror-schema-list: 1.5.1
+ prosemirror-state: 1.4.4
+ prosemirror-tables: 1.8.5
+ prosemirror-transform: 1.12.0
+ prosemirror-view: 1.42.0
+
'@tiptap/react@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@tiptap/core': 2.27.2(@tiptap/pm@2.27.2)
@@ -11633,6 +12124,23 @@ snapshots:
react-dom: 19.2.7(react@19.2.7)
use-sync-external-store: 1.6.0(react@19.2.7)
+ '@tiptap/react@3.28.0(@floating-ui/dom@1.8.0)(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
+ dependencies:
+ '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0)
+ '@tiptap/pm': 3.28.0
+ '@types/react': 19.2.17
+ '@types/react-dom': 19.2.3(@types/react@19.2.17)
+ '@types/use-sync-external-store': 0.0.6
+ fast-equals: 5.4.1
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+ use-sync-external-store: 1.6.0(react@19.2.7)
+ optionalDependencies:
+ '@tiptap/extension-bubble-menu': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)
+ '@tiptap/extension-floating-menu': 3.28.0(@floating-ui/dom@1.8.0)(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)
+ transitivePeerDependencies:
+ - '@floating-ui/dom'
+
'@tiptap/starter-kit@2.27.2':
dependencies:
'@tiptap/core': 2.27.2(@tiptap/pm@2.27.2)
@@ -11957,6 +12465,9 @@ snapshots:
'@types/use-sync-external-store@0.0.6': {}
+ '@types/use-sync-external-store@1.5.0':
+ optional: true
+
'@types/yargs-parser@21.0.3': {}
'@types/yargs@17.0.35':
@@ -13360,6 +13871,8 @@ snapshots:
emittery@0.13.1: {}
+ emoji-mart@5.6.0: {}
+
emoji-regex@10.6.0: {}
emoji-regex@8.0.0: {}
@@ -13857,6 +14370,8 @@ snapshots:
fast-deep-equal@3.1.3: {}
+ fast-equals@5.4.1: {}
+
fast-glob@3.3.1:
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -15155,6 +15670,8 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
+ lib0@1.0.0-rc.22: {}
+
libarchive.js@2.0.2:
dependencies:
comlink: 4.4.2
@@ -16319,6 +16836,8 @@ snapshots:
dependencies:
preact: 11.0.0-beta.0
+ preact@10.12.1: {}
+
preact@11.0.0-beta.0: {}
prelude-ls@1.2.1: {}
@@ -16398,6 +16917,19 @@ snapshots:
prosemirror-state: 1.4.4
prosemirror-view: 1.42.0
+ prosemirror-highlight@0.15.3(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.2.0)(@types/hast@3.0.5)(highlight.js@11.11.1)(prosemirror-model@1.25.10)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.0)(refractor@5.0.0):
+ optionalDependencies:
+ '@lezer/common': 1.5.2
+ '@lezer/highlight': 1.2.3
+ '@shikijs/types': 4.2.0
+ '@types/hast': 3.0.5
+ highlight.js: 11.11.1
+ prosemirror-model: 1.25.10
+ prosemirror-state: 1.4.4
+ prosemirror-transform: 1.12.0
+ prosemirror-view: 1.42.0
+ refractor: 5.0.0
+
prosemirror-history@1.5.0:
dependencies:
prosemirror-state: 1.4.4
@@ -16606,6 +17138,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ react-number-format@5.4.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
+ dependencies:
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+
react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7):
dependencies:
react: 19.2.7
diff --git a/wework/e2e/desktop/scenarios/cloud-space-mention.scenario.mjs b/wework/e2e/desktop/scenarios/cloud-space-mention.scenario.mjs
new file mode 100644
index 0000000000..c8428e257f
--- /dev/null
+++ b/wework/e2e/desktop/scenarios/cloud-space-mention.scenario.mjs
@@ -0,0 +1,279 @@
+import assert from 'node:assert/strict'
+import { writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+
+const ACTIVE_WORKBENCH_SELECTOR =
+ '[data-testid="desktop-workbench-main"][data-active-workbench-pane="true"]'
+const COMPOSER_SELECTOR = `${ACTIVE_WORKBENCH_SELECTOR} [data-testid="chat-message-input"][contenteditable="true"]`
+
+const WEBSITE_PROJECT = {
+ id: '896185331840201807',
+ public_id: 'e2e-public-website',
+ project_key: 'GW',
+ name: '官网改版',
+ description: '官网改版协作空间',
+ created_by_user_id: 9001,
+ status: 'active',
+ version: 1,
+ created_at: '2026-07-25T00:00:00',
+ updated_at: '2026-07-25T00:00:00',
+}
+const MOBILE_PROJECT = {
+ ...WEBSITE_PROJECT,
+ id: '617164117691150677',
+ public_id: 'e2e-public-mobile',
+ project_key: 'MB',
+ name: '移动端重构',
+ description: '',
+}
+const WEBSITE_TODO = {
+ id: 'GW-1',
+ cloud_project_id: WEBSITE_PROJECT.id,
+ sequence_number: 1,
+ parent_id: null,
+ created_by_user_id: 9001,
+ assignee_user_id: null,
+ title: '接入新版登录页',
+ description: '',
+ status: 'in_progress',
+ priority: 'high',
+ due_at: null,
+ sort_order: 0,
+ current_delivery_id: null,
+ version: 1,
+ created_at: '2026-07-25T00:00:00',
+ updated_at: '2026-07-25T00:00:00',
+ completed_at: null,
+}
+const WEBSITE_FILE = {
+ id: '1906534447060216960',
+ cloud_project_id: WEBSITE_PROJECT.id,
+ path: '需求文档.md',
+ name: '需求文档.md',
+ kind: 'file',
+ content_type: 'text/markdown',
+ size_bytes: 12,
+ sha256: null,
+ description: '',
+ created_by_user_id: 9001,
+ updated_by_user_id: 9001,
+ version: 1,
+ created_at: '2026-07-25T00:00:00',
+ updated_at: '2026-07-25T00:00:00',
+}
+
+async function readJson(request) {
+ const chunks = []
+ for await (const chunk of request) chunks.push(chunk)
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
+}
+
+function json(response, status, body) {
+ response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
+ response.end(JSON.stringify(body))
+}
+
+async function capture(control, resultDir, name) {
+ const dataUrl = await control.command('capture', ACTIVE_WORKBENCH_SELECTOR, {
+ timeoutMs: 30_000,
+ })
+ const prefix = 'data:image/png;base64,'
+ assert.ok(dataUrl.startsWith(prefix), 'Desktop screenshot did not return PNG data')
+ await writeFile(join(resultDir, name), Buffer.from(dataUrl.slice(prefix.length), 'base64'))
+}
+
+async function snapshot(control) {
+ return JSON.parse(await control.command('snapshot', ACTIVE_WORKBENCH_SELECTOR))
+}
+
+export function createDesktopScenario({ resultDir, uiTimeoutMs }) {
+ const projects = [WEBSITE_PROJECT, MOBILE_PROJECT]
+ let createdProjectPayload = null
+
+ return {
+ async handleHttp(request, response, url) {
+ if (request.method === 'GET' && url.pathname === '/api/v1/cloud-projects') {
+ json(response, 200, { items: projects })
+ return true
+ }
+ if (request.method === 'POST' && url.pathname === '/api/v1/cloud-projects') {
+ createdProjectPayload = await readJson(request)
+ const created = {
+ ...WEBSITE_PROJECT,
+ id: '702251189240268801',
+ public_id: 'e2e-public-created',
+ project_key: createdProjectPayload.project_key ?? 'E2E',
+ name: createdProjectPayload.name,
+ description: createdProjectPayload.description ?? '',
+ }
+ projects.unshift(created)
+ json(response, 200, created)
+ return true
+ }
+ const loopItemsMatch = url.pathname.match(/^\/api\/v1\/cloud-projects\/([^/]+)\/loop-items$/)
+ if (request.method === 'GET' && loopItemsMatch) {
+ json(response, 200, {
+ items: loopItemsMatch[1] === WEBSITE_PROJECT.id ? [WEBSITE_TODO] : [],
+ })
+ return true
+ }
+ const filesMatch = url.pathname.match(/^\/api\/v1\/cloud-projects\/([^/]+)\/files$/)
+ if (request.method === 'GET' && filesMatch) {
+ json(response, 200, {
+ items: filesMatch[1] === WEBSITE_PROJECT.id ? [WEBSITE_FILE] : [],
+ })
+ return true
+ }
+ return false
+ },
+
+ async verify(control) {
+ await control.command('waitFor', COMPOSER_SELECTOR, { timeoutMs: uiTimeoutMs })
+
+ // Scene 1: the @ menu exposes the direct cloud space row and the project
+ // space list entry; the retired entries are gone.
+ await control.command('fill', COMPOSER_SELECTOR, { value: '@' })
+ await control.command('waitFor', '[data-testid="mention-cloud-space-direct-action"]', {
+ timeoutMs: uiTimeoutMs,
+ })
+ await control.command('waitFor', '[data-testid="mention-cloud-projects-action"]', {
+ timeoutMs: uiTimeoutMs,
+ })
+ const menuSnapshot = await snapshot(control)
+ assert.ok(
+ !menuSnapshot.testIds.includes('mention-cloud-space'),
+ 'The retired cloud space drill entry is still rendered'
+ )
+ assert.ok(
+ !menuSnapshot.testIds.includes('mention-cloud-create-action'),
+ 'The retired create action is still rendered'
+ )
+ await capture(control, resultDir, 'cloud-space-mention-01-menu-entries.png')
+
+ // Scene 2: the direct row inserts the generic cloud://projects chip
+ // without binding anything.
+ await control.command('click', '[data-testid="mention-cloud-space-direct-action"]')
+ const directSnapshot = await snapshot(control)
+ assert.ok(
+ !directSnapshot.testIds.includes('mention-cloud-space-direct-action'),
+ 'Selecting the direct row did not close the mention menu'
+ )
+ assert.ok(
+ directSnapshot.text.includes('项目空间'),
+ 'The direct row did not insert the generic 项目空间 mention chip'
+ )
+ assert.ok(
+ !directSnapshot.testIds.includes('transient-notice'),
+ 'The generic reference unexpectedly bound a cloud context'
+ )
+ await capture(control, resultDir, 'cloud-space-mention-02-direct-chip.png')
+
+ // Scene 3: the project space list drills into every accessible project.
+ await control.command('fill', COMPOSER_SELECTOR, { value: '@' })
+ await control.command('waitFor', '[data-testid="mention-cloud-projects-action"]', {
+ timeoutMs: uiTimeoutMs,
+ })
+ await control.command('click', '[data-testid="mention-cloud-projects-action"]')
+ await control.command(
+ 'waitFor',
+ `[data-testid="cloud-reference-option-cloud-project-space-${WEBSITE_PROJECT.id}"]`,
+ { timeoutMs: uiTimeoutMs }
+ )
+ const drillSnapshot = await snapshot(control)
+ assert.ok(
+ drillSnapshot.testIds.includes(
+ `cloud-reference-option-cloud-project-space-${MOBILE_PROJECT.id}`
+ ),
+ 'The project space drill did not list every accessible cloud project'
+ )
+ assert.ok(
+ drillSnapshot.testIds.includes('mention-cloud-back-action'),
+ 'The project space drill lost its back row'
+ )
+ await capture(control, resultDir, 'cloud-space-mention-03-project-list.png')
+
+ // Scene 4: selecting a project inserts the mention chip and binds the space.
+ await control.command(
+ 'click',
+ `[data-testid="cloud-reference-option-cloud-project-space-${WEBSITE_PROJECT.id}"]`
+ )
+ await control.command('waitFor', '[data-testid="transient-notice"]', {
+ text: '已绑定项目空间',
+ timeoutMs: uiTimeoutMs,
+ })
+ const boundSnapshot = await snapshot(control)
+ assert.ok(
+ boundSnapshot.text.includes('项目空间:官网改版'),
+ 'Selecting a project did not insert the 项目空间 mention chip'
+ )
+
+ // Scene 5: the bound space candidates surface through plain query
+ // filtering, todos carry their status badge.
+ await control.command('fill', COMPOSER_SELECTOR, { value: '@GW' })
+ await control.command('waitFor', '[data-testid="cloud-reference-option-cloud-todo-GW-1"]', {
+ timeoutMs: uiTimeoutMs,
+ })
+ const spaceSnapshot = await snapshot(control)
+ assert.ok(
+ spaceSnapshot.testIds.includes('cloud-reference-status-cloud-todo-GW-1'),
+ 'The todo row did not render its status badge'
+ )
+ await capture(control, resultDir, 'cloud-space-mention-04-bound-filter.png')
+ await control.command('click', '[data-testid="cloud-reference-option-cloud-todo-GW-1"]')
+ const todoSnapshot = await snapshot(control)
+ assert.ok(
+ todoSnapshot.text.includes('任务:GW'),
+ 'Selecting a todo did not insert the 任务 mention chip'
+ )
+
+ // Scene 6: the typed scope pins the direct row above filtered projects.
+ await control.command('fill', COMPOSER_SELECTOR, { value: '@项目空间:官' })
+ await control.command(
+ 'waitFor',
+ `[data-testid="cloud-reference-option-cloud-project-space-${WEBSITE_PROJECT.id}"]`,
+ { timeoutMs: uiTimeoutMs }
+ )
+ const colonSnapshot = await snapshot(control)
+ assert.ok(
+ colonSnapshot.testIds.includes('mention-cloud-space-direct-action'),
+ 'The typed scope lost the pinned direct row'
+ )
+ assert.ok(
+ !colonSnapshot.testIds.includes(
+ `cloud-reference-option-cloud-project-space-${MOBILE_PROJECT.id}`
+ ),
+ 'The @项目空间: keyword did not filter the project list'
+ )
+ await capture(control, resultDir, 'cloud-space-mention-05-typed-scope.png')
+
+ // Scene 7: a non-matching typed phrase keeps only the direct row, which
+ // still inserts the generic chip.
+ await control.command('fill', COMPOSER_SELECTOR, { value: '@项目空间 新建项目' })
+ const emptyScopeSnapshot = await snapshot(control)
+ assert.ok(
+ emptyScopeSnapshot.testIds.includes('mention-cloud-space-direct-action'),
+ 'The typed scope without matches lost the direct row'
+ )
+ assert.ok(
+ !emptyScopeSnapshot.testIds.some(testId =>
+ testId.startsWith('cloud-reference-option-cloud-project-space-')
+ ),
+ 'A non-matching typed phrase still listed project candidates'
+ )
+ assert.ok(
+ !emptyScopeSnapshot.testIds.includes('mention-cloud-create-action'),
+ 'The retired create action reappeared in the typed scope'
+ )
+ await control.command('click', '[data-testid="mention-cloud-space-direct-action"]')
+ const genericChipSnapshot = await snapshot(control)
+ assert.ok(
+ genericChipSnapshot.text.includes('项目空间'),
+ 'The direct row in the typed scope did not insert the generic chip'
+ )
+ },
+
+ diagnostics() {
+ return { createdProjectPayload }
+ },
+ }
+}
diff --git a/wework/package.json b/wework/package.json
index d13e5bd05b..7c316ea3dd 100644
--- a/wework/package.json
+++ b/wework/package.json
@@ -35,6 +35,9 @@
"e2e:ui": "playwright test --ui"
},
"dependencies": {
+ "@blocknote/core": "0.52.1",
+ "@blocknote/mantine": "0.52.1",
+ "@blocknote/react": "0.52.1",
"@chenglou/pretext": "0.0.8",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-css": "^6.3.1",
@@ -55,6 +58,9 @@
"@file-viewer/preset-lite": "2.1.26",
"@file-viewer/preset-office": "2.1.26",
"@file-viewer/react": "2.1.26",
+ "@fullcalendar/core": "^6.1.21",
+ "@fullcalendar/daygrid": "^6.1.21",
+ "@fullcalendar/react": "^6.1.21",
"@headless-tree/core": "^1.7.0",
"@headless-tree/react": "^1.7.0",
"@pierre/diffs": "^1.2.11",
@@ -70,10 +76,12 @@
"@tauri-apps/plugin-opener": "~2.5.4",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
+ "@tiptap/core": "2.27.2",
"@tiptap/extension-link": "^2.27.2",
"@tiptap/extension-placeholder": "^2.27.2",
"@tiptap/extension-task-item": "^2.27.2",
"@tiptap/extension-task-list": "^2.27.2",
+ "@tiptap/pm": "2.27.2",
"@tiptap/react": "^2.27.2",
"@tiptap/starter-kit": "^2.27.2",
"@wegent/chat-core": "workspace:*",
diff --git a/wework/src/api/deliveries.ts b/wework/src/api/deliveries.ts
index 6e207ae9fb..00aae6cefb 100644
--- a/wework/src/api/deliveries.ts
+++ b/wework/src/api/deliveries.ts
@@ -49,6 +49,7 @@ export interface CloudLoopItem {
status: 'inbox' | 'pending' | 'in_progress' | 'in_review' | 'completed'
priority: 'none' | 'low' | 'medium' | 'high' | 'urgent'
due_at: string | null
+ tags: string[]
sort_order: number
current_delivery_id: string | null
version: number
@@ -76,6 +77,7 @@ export interface CloudProject {
description: string
created_by_user_id: number
status: string
+ tags: string[]
version: number
created_at: string
updated_at: string
@@ -178,6 +180,17 @@ export function createDeliveryApi(client: HttpClient) {
}): Promise {
return client.post('/v1/cloud-projects', data)
},
+ updateCloudProject(
+ projectId: CloudProjectIdInput,
+ data: {
+ name?: string
+ description?: string
+ tags?: string[]
+ version: number
+ }
+ ): Promise {
+ return client.patch(`/v1/cloud-projects/${projectId}`, data)
+ },
listMyWork(): Promise<{ items: CloudMyWorkItem[] }> {
return client.get('/v1/cloud-work-items/my-work')
},
@@ -204,6 +217,7 @@ export function createDeliveryApi(client: HttpClient) {
priority?: CloudLoopItem['priority']
due_at?: string
parent_id?: string | null
+ tags?: string[]
}
): Promise {
return client.post(`/v1/cloud-projects/${projectId}/loop-items`, data)
@@ -220,6 +234,7 @@ export function createDeliveryApi(client: HttpClient) {
| 'parent_id'
| 'assignee_user_id'
| 'due_at'
+ | 'tags'
>
> & {
version: number
@@ -227,6 +242,16 @@ export function createDeliveryApi(client: HttpClient) {
): Promise {
return client.patch(`/v1/loop-items/${encodeURIComponent(itemId)}`, data)
},
+ reorderLoopItems(
+ projectId: CloudProjectIdInput,
+ data: {
+ parent_id: string | null
+ status: CloudLoopItem['status']
+ item_ids: string[]
+ }
+ ): Promise<{ items: CloudLoopItem[] }> {
+ return client.post(`/v1/cloud-projects/${projectId}/loop-items/reorder`, data)
+ },
listLoopItemAttachments(itemId: string): Promise {
return client.get(`/v1/loop-items/${encodeURIComponent(itemId)}/attachments`)
},
diff --git a/wework/src/api/local/localServices.test.ts b/wework/src/api/local/localServices.test.ts
index 6f91abf0da..4c79df77fd 100644
--- a/wework/src/api/local/localServices.test.ts
+++ b/wework/src/api/local/localServices.test.ts
@@ -1305,7 +1305,7 @@ describe('createLocalAppServices', () => {
)
expect(payload.executionRequest.mcp_servers).toEqual([
{
- name: 'wegent-delivery',
+ name: 'wegent_delivery',
type: 'streamable-http',
url: 'https://cloud.example.com/custom/api/mcp/delivery/sse',
headers: { Authorization: 'Bearer cloud-login-token' },
@@ -1379,7 +1379,7 @@ describe('createLocalAppServices', () => {
const additionalContext = {
cloudCollaboration: {
kind: 'application' as const,
- value: 'Current TODO: WEG-1. Use the wegent-delivery MCP tools when needed.',
+ value: 'Current TODO: WEG-1. Use the wegent_delivery MCP tools when needed.',
},
}
@@ -1414,6 +1414,31 @@ describe('createLocalAppServices', () => {
expect(sendPayload.executionRequest.prompt).toContain('Current TODO: WEG-1')
})
+ test('activates project-space capabilities for a generic cloud reference', async () => {
+ const request = vi.fn().mockResolvedValue({ accepted: true })
+ const services = createLocalAppServices({
+ ensure: vi.fn().mockResolvedValue({ running: true, ready: true, deviceId: 'device-uuid' }),
+ request,
+ subscribe: vi.fn(),
+ })
+
+ await services.runtimeWorkApi?.createRuntimeTask({
+ teamId: 0,
+ deviceId: 'local-device',
+ workspacePath: '/Users/me/project',
+ taskId: 'task-project-space',
+ runtime: 'codex',
+ message: '[$项目空间](cloud://projects) 帮我创建一个新项目',
+ })
+
+ const payload = request.mock.calls.find(([method]) => method === 'runtime.tasks.create')?.[1]
+ const prompt = payload.executionRequest.prompt as string
+ expect(prompt).toContain('[projectSpaceCapability]')
+ expect(prompt).toContain('wegent_delivery is a server id, not a callable tool')
+ expect(prompt).toContain('create_cloud_project')
+ expect(prompt).toContain('do not use list_mcp_resources to discover tools')
+ })
+
test('adds configured local proxy to local runtime execution requests', async () => {
saveLocalProxyUrl('http://127.0.0.1:7890')
const request = vi.fn().mockResolvedValue({ accepted: true })
diff --git a/wework/src/api/local/localServices.ts b/wework/src/api/local/localServices.ts
index 2a817b531b..794044062e 100644
--- a/wework/src/api/local/localServices.ts
+++ b/wework/src/api/local/localServices.ts
@@ -1141,6 +1141,22 @@ function messageWithApplicationContext(
context?: RuntimeTaskCreateRequest['additionalContext']
): string {
const entries = Object.entries(context ?? {}).filter(([, entry]) => entry.kind === 'application')
+ if (message.includes('cloud://projects') && !context?.projectSpaceCapability) {
+ entries.push([
+ 'projectSpaceCapability',
+ {
+ kind: 'application',
+ value: [
+ 'The user activated the Wegent project-space capability.',
+ 'Use the wegent_delivery MCP server for project-space operations.',
+ 'wegent_delivery is a server id, not a callable tool.',
+ 'Use list_cloud_projects to list projects and create_cloud_project to create one.',
+ 'Use resolve_cloud_reference to resolve cloud:// references.',
+ 'MCP resources describe addressable data; do not use list_mcp_resources to discover tools.',
+ ].join('\n'),
+ },
+ ])
+ }
if (entries.length === 0) return message
const contextText = entries.map(([name, entry]) => `[${name}]\n${entry.value}`).join('\n\n')
return `\n${contextText}\n\n\n${message}`
@@ -1194,7 +1210,7 @@ function buildLocalRuntimeExecutionRequest(
mcp_servers: input.cloudModelGateway?.mcpUrl
? [
{
- name: 'wegent-delivery',
+ name: 'wegent_delivery',
type: 'streamable-http',
url: input.cloudModelGateway.mcpUrl,
headers: {
diff --git a/wework/src/components/chat/ChatInput.tsx b/wework/src/components/chat/ChatInput.tsx
index cf2ee2b60b..c10182ad15 100644
--- a/wework/src/components/chat/ChatInput.tsx
+++ b/wework/src/components/chat/ChatInput.tsx
@@ -21,6 +21,7 @@ import type {
} from '@/types/api'
import type { GuidanceWorkbenchMessage, QueuedWorkbenchMessage } from '@/types/workbench'
import type { CodeCommentContext, WorkspaceFileApi, WorkspaceTarget } from '@/types/workspace-files'
+import type { CloudProject } from '@/api/deliveries'
import type { ComposerCloudMentionCandidate } from './composer/composerMentionCandidates'
import { ConversationQueuePanel } from './ConversationQueuePanel'
import { CompactChatComposer } from './composer/CompactChatComposer'
@@ -125,6 +126,9 @@ export interface ChatInputProps {
workspaceTarget?: WorkspaceTarget | null
workspaceFileApi?: WorkspaceFileApi
cloudMentionCandidates?: ComposerCloudMentionCandidate[]
+ cloudProjectCandidates?: ComposerCloudMentionCandidate[]
+ cloudSpaceEnabled?: boolean
+ onSelectCloudProject?: (project: CloudProject) => void
isStreaming?: boolean
onPause?: () => void
toolbarLeadingContext?: ReactNode
@@ -231,6 +235,9 @@ export function ChatInput({
workspaceTarget,
workspaceFileApi,
cloudMentionCandidates,
+ cloudProjectCandidates,
+ cloudSpaceEnabled,
+ onSelectCloudProject,
isStreaming = false,
onPause,
toolbarLeadingContext,
@@ -337,6 +344,9 @@ export function ChatInput({
workspaceTarget,
workspaceFileApi,
cloudMentionCandidates,
+ cloudProjectCandidates,
+ cloudSpaceEnabled,
+ onSelectCloudProject,
}
const errorBanner = error ? (
void
planModeActive?: boolean
onSetPlanMode?: () => void
onClearPlanMode?: () => void
@@ -90,6 +94,9 @@ export function CompactChatComposer({
workspaceTarget,
workspaceFileApi,
cloudMentionCandidates,
+ cloudProjectCandidates,
+ cloudSpaceEnabled,
+ onSelectCloudProject,
planModeActive = false,
onSetPlanMode,
onClearPlanMode,
@@ -279,6 +286,9 @@ export function CompactChatComposer({
workspaceTarget={workspaceTarget}
workspaceFileApi={workspaceFileApi}
cloudMentionCandidates={cloudMentionCandidates}
+ cloudProjectCandidates={cloudProjectCandidates}
+ cloudSpaceEnabled={cloudSpaceEnabled}
+ onSelectCloudProject={onSelectCloudProject}
className="scrollbar-none max-h-32 min-h-6 min-w-0 flex-1 resize-none overflow-y-auto bg-transparent py-[14px] text-chat leading-5 text-text-primary outline-none placeholder:text-text-muted"
skillMenuClassName={[
'left-[-1rem]',
@@ -465,6 +475,9 @@ export function CompactChatComposer({
workspaceTarget={workspaceTarget}
workspaceFileApi={workspaceFileApi}
cloudMentionCandidates={cloudMentionCandidates}
+ cloudProjectCandidates={cloudProjectCandidates}
+ cloudSpaceEnabled={cloudSpaceEnabled}
+ onSelectCloudProject={onSelectCloudProject}
className="h-full w-full overflow-y-auto rounded-2xl border border-border bg-background px-4 pb-4 pt-14 text-chat text-text-primary outline-none"
skillMenuClassName="left-4 right-4 bottom-[calc(100%+0.5rem)]"
onListLocalSkills={onListLocalSkills}
diff --git a/wework/src/components/chat/composer/ComposerMentionMenu.tsx b/wework/src/components/chat/composer/ComposerMentionMenu.tsx
index 2c09263974..46be76dcec 100644
--- a/wework/src/components/chat/composer/ComposerMentionMenu.tsx
+++ b/wework/src/components/chat/composer/ComposerMentionMenu.tsx
@@ -20,8 +20,9 @@ export type MentionMenuRow =
| { kind: 'files-action' }
| { kind: 'goal-action' }
| { kind: 'plan-action' }
- | { kind: 'cloud-action'; candidate: ComposerMentionCandidate }
| { kind: 'cloud-back-action' }
+ | { kind: 'cloud-space-direct-action' }
+ | { kind: 'cloud-projects-action' }
interface ComposerMentionMenuProps {
menuRef: RefObject
@@ -29,7 +30,7 @@ interface ComposerMentionMenuProps {
selectedIndex: number
className: string
mentionMode: boolean
- cloudScope: boolean
+ projectSpaceScope?: boolean
loading: boolean
error: boolean
canBrowseFiles: boolean
@@ -44,7 +45,7 @@ export function ComposerMentionMenu({
selectedIndex,
className,
mentionMode,
- cloudScope,
+ projectSpaceScope = false,
loading,
error,
canBrowseFiles,
@@ -65,8 +66,8 @@ export function ComposerMentionMenu({
].join(' ')}
>
- {cloudScope
- ? t('workbench.mention_cloud_space', '云空间')
+ {projectSpaceScope
+ ? t('workbench.mention_cloud_project_space', '项目空间')
: mentionMode
? t('workbench.mention_add', '添加')
: t('workbench.local_skills', '技能')}
@@ -100,57 +101,67 @@ export function ComposerMentionMenu({
) : (
rows.map((row, index) => {
const candidate = row.kind === 'candidate' ? row.candidate : null
- const cloudAction = row.kind === 'cloud-action' ? row.candidate : null
const pathItem = row.kind === 'path' ? row.item : null
const enabled = candidate
? candidate.enabled
: row.kind !== 'files-action' || canBrowseFiles
- const Icon = cloudAction
- ? Cloud
+ const Icon = pathItem
+ ? pathItem.matchType === 'directory'
+ ? Folder
+ : File
+ : row.kind === 'files-action'
+ ? Paperclip
+ : row.kind === 'goal-action'
+ ? Target
+ : row.kind === 'plan-action'
+ ? ClipboardList
+ : row.kind === 'cloud-space-direct-action' ||
+ row.kind === 'cloud-projects-action' ||
+ candidate?.kind === 'cloud'
+ ? Cloud
+ : row.kind === 'cloud-back-action'
+ ? ArrowLeft
+ : Package
+ const title = candidate
+ ? candidate.title
: pathItem
- ? pathItem.matchType === 'directory'
- ? Folder
- : File
+ ? pathItem.fileName
: row.kind === 'files-action'
- ? Paperclip
+ ? t('workbench.mention_files_and_folders', '文件和文件夹')
: row.kind === 'goal-action'
- ? Target
- : row.kind === 'plan-action'
- ? ClipboardList
- : candidate?.kind === 'cloud'
- ? Cloud
- : row.kind === 'cloud-back-action'
- ? ArrowLeft
- : Package
- const title = candidate
- ? candidate.title
- : cloudAction
- ? cloudAction.title
- : pathItem
- ? pathItem.fileName
- : row.kind === 'files-action'
- ? t('workbench.mention_files_and_folders', '文件和文件夹')
- : row.kind === 'goal-action'
- ? t('workbench.goal_chip', '目标')
- : row.kind === 'cloud-back-action'
- ? t('workbench.mention_cloud_back', '返回')
- : t('workbench.plan_mode', '计划模式')
+ ? t('workbench.goal_chip', '目标')
+ : row.kind === 'cloud-back-action'
+ ? t('workbench.mention_cloud_back', '返回')
+ : row.kind === 'cloud-space-direct-action'
+ ? t('workbench.mention_cloud_project_space', '项目空间')
+ : row.kind === 'cloud-projects-action'
+ ? t('workbench.mention_cloud_project_space_list', '项目空间列表')
+ : t('workbench.plan_mode', '计划模式')
const description =
candidate?.description ??
- cloudAction?.description ??
- (pathItem ? parentComposerPath(pathItem.path) : undefined)
+ (row.kind === 'cloud-space-direct-action'
+ ? t(
+ 'workbench.mention_cloud_project_space_description',
+ '@ 我的云空间能力,用自然语言做任何有权限的操作'
+ )
+ : row.kind === 'cloud-projects-action'
+ ? t(
+ 'workbench.mention_cloud_project_space_list_description',
+ '列出所有可访问的云项目空间'
+ )
+ : pathItem
+ ? parentComposerPath(pathItem.path)
+ : undefined)
return (
)
})
diff --git a/wework/src/components/chat/composer/ComposerTextarea.test.tsx b/wework/src/components/chat/composer/ComposerTextarea.test.tsx
index e6d8b9513d..3f683c5fb6 100644
--- a/wework/src/components/chat/composer/ComposerTextarea.test.tsx
+++ b/wework/src/components/chat/composer/ComposerTextarea.test.tsx
@@ -1,6 +1,7 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { createRef, useState } from 'react'
import { beforeEach, describe, expect, test, vi } from 'vitest'
+import type { CloudProject } from '@/api/deliveries'
import type { LocalDeviceSkill } from '@/types/api'
import type { WorkspaceFileApi, WorkspaceTarget } from '@/types/workspace-files'
import type { ComposerCloudMentionCandidate } from './composerMentionCandidates'
@@ -24,6 +25,42 @@ const GMAIL_SKILL: LocalDeviceSkill = {
}
const GMAIL_REFERENCE = '[$gmail](/tmp/gmail/SKILL.md)'
+const WEBSITE_PROJECT: CloudProject = {
+ id: '7',
+ public_id: 'pub-7',
+ project_key: 'GW',
+ name: '官网改版',
+ description: '官网改版协作空间',
+ created_by_user_id: 1,
+ status: 'active',
+ version: 1,
+ created_at: '2026-07-01T00:00:00Z',
+ updated_at: '2026-07-01T00:00:00Z',
+}
+const MOBILE_PROJECT: CloudProject = {
+ ...WEBSITE_PROJECT,
+ id: '9',
+ public_id: 'pub-9',
+ project_key: 'MB',
+ name: '移动端重构',
+ description: '',
+}
+
+function cloudProjectCandidate(project: CloudProject): ComposerCloudMentionCandidate {
+ return {
+ kind: 'cloud',
+ key: `cloud-project-space:${project.id}`,
+ title: project.name,
+ description: project.description || project.project_key,
+ metaLabel: '云空间',
+ testId: `cloud-project-space-${project.id}`,
+ enabled: true,
+ reference: `[$项目空间:${project.name}](cloud://projects/${project.id})`,
+ searchAliases: [project.name, project.project_key, '项目空间', 'project space'],
+ project,
+ }
+}
+
describe('ComposerTextarea', () => {
beforeEach(() => {
nativeWorkspacePickerMocks.open.mockReset()
@@ -189,7 +226,7 @@ describe('ComposerTextarea', () => {
await waitFor(() => expect(editor.value).toBe(`${reference} `))
})
- test('drills into cloud space instead of flattening cloud references', async () => {
+ test('keeps cloud references out of the root menu and filters them by query', async () => {
const textareaRef = createRef
()
const cloudCandidates: ComposerCloudMentionCandidate[] = [
{
@@ -235,13 +272,17 @@ describe('ComposerTextarea', () => {
editor.focus()
})
- expect(await screen.findByTestId('mention-cloud-space')).toBeInTheDocument()
+ // Cloud candidates stay out of the root menu; there is no drill entry.
+ await screen.findByTestId('mention-files-action')
+ expect(screen.queryByTestId('mention-cloud-space')).toBeNull()
expect(screen.queryByText('README.md')).not.toBeInTheDocument()
- fireEvent.click(screen.getByTestId('mention-cloud-space'))
+ // They surface through plain query filtering instead.
+ act(() => {
+ editor.value = '@README'
+ })
expect(await screen.findByTestId('cloud-reference-option-cloud-file-42')).toBeInTheDocument()
- expect(screen.getByText('返回')).toBeInTheDocument()
- expect(screen.queryByTestId('local-skill-source-cloud-file-42')).not.toBeInTheDocument()
+ expect(screen.getByTestId('local-skill-source-cloud-file-42')).toBeInTheDocument()
})
test('searches the active workspace for an @ token and inserts the relative path', async () => {
@@ -420,4 +461,359 @@ describe('ComposerTextarea', () => {
expect(screen.getByTestId('composer-path-chip-frontend')).toHaveTextContent('frontend')
})
})
+
+ test('shows the cloud space entries in the @ menu when cloud is enabled', async () => {
+ const textareaRef = createRef()
+
+ function Harness() {
+ const [value, setValue] = useState('')
+ return (
+
+ )
+ }
+
+ render()
+ const editor = screen.getByTestId('chat-message-input') as HTMLElement & { value: string }
+ act(() => {
+ editor.value = '@'
+ editor.focus()
+ })
+
+ expect(await screen.findByTestId('mention-cloud-space-direct-action')).toHaveTextContent(
+ '项目空间'
+ )
+ expect(screen.getByTestId('mention-cloud-projects-action')).toHaveTextContent('项目空间列表')
+ expect(screen.queryByTestId('mention-cloud-create-action')).toBeNull()
+ expect(screen.queryByTestId('mention-cloud-space')).toBeNull()
+ })
+
+ test('hides the cloud space entries when cloud is disabled', async () => {
+ const textareaRef = createRef()
+
+ function Harness() {
+ const [value, setValue] = useState('')
+ return (
+
+ )
+ }
+
+ render()
+ const editor = screen.getByTestId('chat-message-input') as HTMLElement & { value: string }
+ act(() => {
+ editor.value = '@'
+ editor.focus()
+ })
+
+ await screen.findByTestId('mention-files-action')
+ expect(screen.queryByTestId('mention-cloud-space-direct-action')).toBeNull()
+ expect(screen.queryByTestId('mention-cloud-projects-action')).toBeNull()
+ })
+
+ test('inserts the generic cloud space chip from the direct row', async () => {
+ const textareaRef = createRef()
+ const onSelectCloudProject = vi.fn()
+
+ function Harness() {
+ const [value, setValue] = useState('')
+ return (
+
+ )
+ }
+
+ render()
+ const editor = screen.getByTestId('chat-message-input') as HTMLElement & { value: string }
+ act(() => {
+ editor.value = '@'
+ editor.focus()
+ })
+
+ fireEvent.click(await screen.findByTestId('mention-cloud-space-direct-action'))
+ await waitFor(() => expect(editor.value).toBe('[$项目空间](cloud://projects) '))
+ // The generic reference never binds a project.
+ expect(onSelectCloudProject).not.toHaveBeenCalled()
+ })
+
+ test('drills into cloud project spaces and binds the selected project', async () => {
+ const textareaRef = createRef()
+ const onSelectCloudProject = vi.fn()
+
+ function Harness() {
+ const [value, setValue] = useState('')
+ return (
+
+ )
+ }
+
+ render()
+ const editor = screen.getByTestId('chat-message-input') as HTMLElement & { value: string }
+ act(() => {
+ editor.value = '@'
+ editor.focus()
+ })
+
+ fireEvent.click(await screen.findByTestId('mention-cloud-projects-action'))
+ expect(await screen.findByTestId('mention-cloud-back-action')).toBeInTheDocument()
+ expect(screen.getByTestId('cloud-reference-option-cloud-project-space-7')).toBeInTheDocument()
+ expect(screen.getByTestId('cloud-reference-option-cloud-project-space-9')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByTestId('cloud-reference-option-cloud-project-space-7'))
+ await waitFor(() => expect(editor.value).toBe('[$项目空间:官网改版](cloud://projects/7) '))
+ expect(onSelectCloudProject).toHaveBeenCalledWith(WEBSITE_PROJECT)
+ })
+
+ test('filters cloud project spaces with the @项目空间: colon syntax', async () => {
+ const textareaRef = createRef()
+ const onSelectCloudProject = vi.fn()
+
+ function Harness() {
+ const [value, setValue] = useState('')
+ return (
+
+ )
+ }
+
+ render()
+ const editor = screen.getByTestId('chat-message-input') as HTMLElement & { value: string }
+ act(() => {
+ editor.value = '@项目空间:官'
+ editor.focus()
+ })
+
+ // The direct row stays pinned above the filtered project candidates.
+ expect(await screen.findByTestId('mention-cloud-space-direct-action')).toBeInTheDocument()
+ expect(await screen.findByTestId('cloud-reference-option-cloud-project-space-7'))
+ expect(screen.queryByTestId('cloud-reference-option-cloud-project-space-9')).toBeNull()
+
+ fireEvent.click(screen.getByTestId('cloud-reference-option-cloud-project-space-7'))
+ await waitFor(() => expect(editor.value).toBe('[$项目空间:官网改版](cloud://projects/7) '))
+ expect(onSelectCloudProject).toHaveBeenCalledWith(WEBSITE_PROJECT)
+ })
+
+ test('keeps the direct row in the typed scope when no project matches', async () => {
+ const textareaRef = createRef()
+
+ function Harness() {
+ const [value, setValue] = useState('')
+ return (
+
+ )
+ }
+
+ render()
+ const editor = screen.getByTestId('chat-message-input') as HTMLElement & { value: string }
+ act(() => {
+ editor.value = '@项目空间 新建项目'
+ editor.focus()
+ })
+
+ // The menu stays open across whitespace inside the scope; the phrase
+ // matches no project, so only the direct row remains (no create action).
+ expect(await screen.findByTestId('mention-cloud-space-direct-action')).toBeInTheDocument()
+ expect(screen.queryByTestId('cloud-reference-option-cloud-project-space-7')).toBeNull()
+ expect(screen.queryByTestId('mention-cloud-create-action')).toBeNull()
+
+ fireEvent.click(screen.getByTestId('mention-cloud-space-direct-action'))
+ await waitFor(() => expect(editor.value).toBe('[$项目空间](cloud://projects) '))
+ })
+
+ test('filters cloud project spaces with the typed scope @项目空间 keyword', async () => {
+ const textareaRef = createRef()
+ const onSelectCloudProject = vi.fn()
+
+ function Harness() {
+ const [value, setValue] = useState('')
+ return (
+
+ )
+ }
+
+ render()
+ const editor = screen.getByTestId('chat-message-input') as HTMLElement & { value: string }
+ act(() => {
+ editor.value = '@项目空间 官'
+ editor.focus()
+ })
+
+ expect(await screen.findByTestId('cloud-reference-option-cloud-project-space-7'))
+ expect(screen.queryByTestId('cloud-reference-option-cloud-project-space-9')).toBeNull()
+
+ fireEvent.click(screen.getByTestId('cloud-reference-option-cloud-project-space-7'))
+ await waitFor(() => expect(editor.value).toBe('[$项目空间:官网改版](cloud://projects/7) '))
+ expect(onSelectCloudProject).toHaveBeenCalledWith(WEBSITE_PROJECT)
+ })
+
+ test('filters bound cloud space candidates inline with todo status badges', async () => {
+ const textareaRef = createRef()
+ const cloudCandidates: ComposerCloudMentionCandidate[] = [
+ {
+ kind: 'cloud',
+ key: 'cloud-project:11',
+ title: '整个空间',
+ description: '共享文件 + 看板全部内容',
+ metaLabel: '云空间',
+ testId: 'cloud-project-11',
+ enabled: true,
+ reference: '[$整个空间](cloud://projects/11)',
+ searchAliases: ['cloud'],
+ },
+ {
+ kind: 'cloud',
+ key: 'cloud-todo:WEG-18',
+ title: 'WEG-18',
+ description: '接入新版登录页',
+ metaLabel: '云空间',
+ testId: 'cloud-todo-WEG-18',
+ enabled: true,
+ reference: '[$任务:WEG-18](cloud://projects/11/todos/WEG-18)',
+ searchAliases: ['接入新版登录页'],
+ statusLabel: '进行中',
+ },
+ {
+ kind: 'cloud',
+ key: 'cloud-file:42',
+ title: 'README.md',
+ description: 'README.md',
+ metaLabel: '云空间',
+ testId: 'cloud-file-42',
+ enabled: true,
+ reference: '[$README.md](cloud://projects/11/files/42)',
+ searchAliases: ['README.md'],
+ },
+ ]
+
+ function Harness() {
+ const [value, setValue] = useState('')
+ return (
+
+ )
+ }
+
+ render()
+ const editor = screen.getByTestId('chat-message-input') as HTMLElement & { value: string }
+ act(() => {
+ editor.value = '@'
+ editor.focus()
+ })
+
+ // The bound-space drill entry is gone from the root menu.
+ expect(screen.queryByTestId('mention-cloud-space')).toBeNull()
+
+ // Typing a query filters the bound space candidates inline instead.
+ act(() => {
+ editor.value = '@WEG'
+ })
+
+ const todoRow = await screen.findByTestId('cloud-reference-option-cloud-todo-WEG-18')
+ expect(todoRow).toHaveTextContent('接入新版登录页')
+ expect(screen.getByTestId('cloud-reference-status-cloud-todo-WEG-18')).toHaveTextContent(
+ '进行中'
+ )
+
+ fireEvent.click(todoRow)
+ await waitFor(() =>
+ expect(editor.value).toBe('[$任务:WEG-18](cloud://projects/11/todos/WEG-18) ')
+ )
+ })
})
diff --git a/wework/src/components/chat/composer/ComposerTextarea.tsx b/wework/src/components/chat/composer/ComposerTextarea.tsx
index ed0dae6e0d..ee5de99368 100644
--- a/wework/src/components/chat/composer/ComposerTextarea.tsx
+++ b/wework/src/components/chat/composer/ComposerTextarea.tsx
@@ -20,8 +20,10 @@ import {
filterSlashCommands,
findStandaloneTrigger,
hasDraftTextForSlashCommands,
+ parseCloudProjectScopeQuery,
} from './composerAutocomplete'
import {
+ matchesMentionQuery,
slashAppTestId,
slashSkillTestId,
type ComposerMentionCandidate,
@@ -65,6 +67,9 @@ export function ComposerTextarea({
workspaceTarget,
workspaceFileApi,
cloudMentionCandidates = [],
+ cloudProjectCandidates = [],
+ cloudSpaceEnabled = false,
+ onSelectCloudProject,
onListLocalSkills,
onListLocalApps,
models = [],
@@ -109,7 +114,7 @@ export function ComposerTextarea({
const [loadError, setLoadError] = useState(false)
const [appsLoading, setAppsLoading] = useState(false)
const [appsLoadError, setAppsLoadError] = useState(false)
- const [cloudMentionOpen, setCloudMentionOpen] = useState(false)
+ const [cloudProjectsOpen, setCloudProjectsOpen] = useState(false)
const canPickNativeWorkspacePaths =
canOpenNativeWorkspacePathPicker() && workspaceTarget?.workspaceSource !== 'remote'
@@ -134,6 +139,40 @@ export function ComposerTextarea({
workspaceFileApi
)
+ // The `@项目空间:keyword` scope syntax drills straight into the cloud project
+ // list without clicking the menu entry, matching the other mention flows.
+ const cloudProjectScopeLabels = useMemo(
+ () => [
+ t('workbench.mention_cloud_project_space', '项目空间'),
+ '项目空间',
+ 'project space',
+ 'project-space',
+ ],
+ [t]
+ )
+ const cloudProjectScopeLabelsRef = useRef(cloudProjectScopeLabels)
+ useEffect(() => {
+ cloudProjectScopeLabelsRef.current = cloudProjectScopeLabels
+ }, [cloudProjectScopeLabels])
+ const cloudProjectScopeKeyword =
+ activeMenu?.kind === 'mention'
+ ? parseCloudProjectScopeQuery(activeMenu.trigger.query, cloudProjectScopeLabels)
+ : null
+ const cloudProjectScopeActive = cloudSpaceEnabled && cloudProjectScopeKeyword !== null
+ const filteredCloudProjectCandidates = useMemo(
+ () =>
+ cloudProjectScopeActive
+ ? cloudProjectCandidates.filter(candidate =>
+ matchesMentionQuery(candidate, cloudProjectScopeKeyword ?? '')
+ )
+ : cloudProjectCandidates,
+ [cloudProjectCandidates, cloudProjectScopeActive, cloudProjectScopeKeyword]
+ )
+ // The direct cloud space reference inserted by the `项目空间` row. Selecting it
+ // never binds a project; it just tags the message with the generic
+ // `cloud://projects` capability reference.
+ const cloudSpaceDirectReference = `[$${t('workbench.mention_cloud_project_space', '项目空间')}](cloud://projects)`
+
const canOpenSlashModelMenu = isModelSelectionReady && Boolean(onSelectModel) && models.length > 0
const openSlashModelMenu = useCallback(() => {
setModelQuery('')
@@ -252,19 +291,15 @@ export function ComposerTextarea({
return filteredMentionCandidates.map(candidate => ({ kind: 'candidate', candidate }))
}
if (!activeMenu?.trigger.query.trim()) {
- const cloudRoot = filteredMentionCandidates.find(
- candidate => candidate.kind === 'cloud' && candidate.key.startsWith('cloud-project:')
- )
- const cloudChildren = filteredMentionCandidates.filter(
- candidate => candidate.kind === 'cloud' && !candidate.key.startsWith('cloud-project:')
- )
const nonCloudCandidates = filteredMentionCandidates.filter(
candidate => candidate.kind !== 'cloud'
)
- if (cloudMentionOpen && cloudRoot) {
+ if (cloudProjectsOpen && cloudProjectCandidates.length > 0) {
return [
{ kind: 'cloud-back-action' },
- ...cloudChildren.map(candidate => ({ kind: 'candidate', candidate }) as MentionMenuRow),
+ ...cloudProjectCandidates.map(
+ candidate => ({ kind: 'candidate', candidate }) as MentionMenuRow
+ ),
]
}
return [
@@ -273,14 +308,23 @@ export function ComposerTextarea({
...(!planModeActive && onSetPlanMode
? ([{ kind: 'plan-action' }] as MentionMenuRow[])
: []),
- ...(cloudRoot
- ? ([{ kind: 'cloud-action', candidate: cloudRoot }] as MentionMenuRow[])
+ ...(cloudSpaceEnabled ? ([{ kind: 'cloud-space-direct-action' }] as MentionMenuRow[]) : []),
+ ...(cloudSpaceEnabled && cloudProjectCandidates.length > 0
+ ? ([{ kind: 'cloud-projects-action' }] as MentionMenuRow[])
: []),
...nonCloudCandidates.map(
candidate => ({ kind: 'candidate', candidate }) as MentionMenuRow
),
]
}
+ if (cloudProjectScopeActive) {
+ return [
+ { kind: 'cloud-space-direct-action' },
+ ...filteredCloudProjectCandidates.map(
+ candidate => ({ kind: 'candidate', candidate }) as MentionMenuRow
+ ),
+ ]
+ }
return [
...filteredMentionCandidates.map(
candidate => ({ kind: 'candidate', candidate }) as MentionMenuRow
@@ -289,7 +333,11 @@ export function ComposerTextarea({
]
}, [
activeMenu,
- cloudMentionOpen,
+ cloudProjectCandidates,
+ cloudProjectScopeActive,
+ cloudProjectsOpen,
+ cloudSpaceEnabled,
+ filteredCloudProjectCandidates,
filteredMentionCandidates,
onSetGoal,
onSetPlanMode,
@@ -442,7 +490,16 @@ export function ComposerTextarea({
if (!current) return
const nextTrigger = chooseNearestTrigger([
- findStandaloneTrigger(current.value, current.selectionOffset, '@', 'mention'),
+ findStandaloneTrigger(
+ current.value,
+ current.selectionOffset,
+ '@',
+ 'mention',
+ // Keep the trigger alive across whitespace once the query is inside
+ // the `@项目空间 keyword` scope so typed phrases like
+ // `@项目空间 新建项目` keep filtering instead of closing the menu.
+ query => parseCloudProjectScopeQuery(query, cloudProjectScopeLabelsRef.current) !== null
+ ),
onListLocalSkills
? findStandaloneTrigger(current.value, current.selectionOffset, '$', 'skill')
: null,
@@ -467,7 +524,7 @@ export function ComposerTextarea({
if (!triggerUnchanged) {
setSelectedIndex(0)
highlightedIndexRef.current = 0
- setCloudMentionOpen(false)
+ setCloudProjectsOpen(false)
}
if (
nextTrigger.kind === 'skill' ||
@@ -577,22 +634,38 @@ export function ComposerTextarea({
if (!trigger || !editor) return false
if (row.kind === 'candidate') {
if (!row.candidate.enabled) return false
- return selectMentionCandidate(row.candidate, trigger)
+ const selected = selectMentionCandidate(row.candidate, trigger)
+ if (selected && row.candidate.kind === 'cloud' && row.candidate.project) {
+ onSelectCloudProject?.(row.candidate.project)
+ }
+ return selected
}
- if (row.kind === 'cloud-action') {
- setCloudMentionOpen(true)
+ if (row.kind === 'cloud-projects-action') {
+ setCloudProjectsOpen(true)
setSelectedIndex(0)
highlightedIndexRef.current = 0
return true
}
if (row.kind === 'cloud-back-action') {
- setCloudMentionOpen(false)
+ setCloudProjectsOpen(false)
setSelectedIndex(0)
highlightedIndexRef.current = 0
return true
}
const snapshot = editor.getSnapshot()
+ if (row.kind === 'cloud-space-direct-action') {
+ const replacement = replaceComposerMentionTrigger(
+ snapshot.value,
+ cloudSpaceDirectReference,
+ trigger.start,
+ snapshot.selectionEnd
+ )
+ commitEditorValue(replacement.value, replacement.cursor)
+ closeAutocompleteMenu()
+ editor.focus()
+ return true
+ }
if (row.kind === 'path') {
const path = resolveComposerWorkspacePath(row.item.root, row.item.path)
const reference = createComposerPathReference(path, row.item.matchType === 'directory')
@@ -646,7 +719,9 @@ export function ComposerTextarea({
},
[
closeAutocompleteMenu,
+ cloudSpaceDirectReference,
commitEditorValue,
+ onSelectCloudProject,
onSetGoal,
onSetPlanMode,
selectMentionCandidate,
@@ -1010,7 +1085,7 @@ export function ComposerTextarea({
selectedIndex={highlightedIndex}
className={skillMenuClassName}
mentionMode={activeMenu?.kind === 'mention'}
- cloudScope={cloudMentionOpen && !activeMenu?.trigger.query.trim()}
+ projectSpaceScope={cloudProjectsOpen || cloudProjectScopeActive}
loading={isMentionLoading || workspaceSearch.loading}
error={hasMentionLoadError || workspaceSearch.error}
canBrowseFiles={canPickNativeWorkspacePaths}
diff --git a/wework/src/components/chat/composer/ProjectChatComposer.tsx b/wework/src/components/chat/composer/ProjectChatComposer.tsx
index 50270785d2..6a2241533b 100644
--- a/wework/src/components/chat/composer/ProjectChatComposer.tsx
+++ b/wework/src/components/chat/composer/ProjectChatComposer.tsx
@@ -18,6 +18,7 @@ import { useAutoResizeTextarea } from './useAutoResizeTextarea'
import { debugComposerEvent, textMetrics } from './composerDebug'
import type { QuickPhrase } from '@/tauri/appPreferences'
import { readDroppedFiles } from '@/tauri/droppedFiles'
+import type { CloudProject } from '@/api/deliveries'
import type { ComposerCloudMentionCandidate } from './composerMentionCandidates'
interface ProjectChatComposerProps {
@@ -49,6 +50,9 @@ interface ProjectChatComposerProps {
workspaceTarget?: WorkspaceTarget | null
workspaceFileApi?: WorkspaceFileApi
cloudMentionCandidates?: ComposerCloudMentionCandidate[]
+ cloudProjectCandidates?: ComposerCloudMentionCandidate[]
+ cloudSpaceEnabled?: boolean
+ onSelectCloudProject?: (project: CloudProject) => void
planModeActive?: boolean
onSetPlanMode?: () => void
onClearPlanMode?: () => void
@@ -100,6 +104,9 @@ export function ProjectChatComposer({
workspaceTarget,
workspaceFileApi,
cloudMentionCandidates,
+ cloudProjectCandidates,
+ cloudSpaceEnabled,
+ onSelectCloudProject,
planModeActive = false,
onSetPlanMode,
onClearPlanMode,
@@ -257,6 +264,9 @@ export function ProjectChatComposer({
workspaceTarget={workspaceTarget}
workspaceFileApi={workspaceFileApi}
cloudMentionCandidates={cloudMentionCandidates}
+ cloudProjectCandidates={cloudProjectCandidates}
+ cloudSpaceEnabled={cloudSpaceEnabled}
+ onSelectCloudProject={onSelectCloudProject}
className="max-h-[112px] min-h-[48px] w-full resize-none overflow-y-auto bg-transparent px-0 pb-0 pt-1 text-chat text-text-primary outline-none placeholder:text-text-muted/55"
skillMenuClassName="left-[-1rem] right-[-0.5rem]"
onListLocalSkills={onListLocalSkills}
diff --git a/wework/src/components/chat/composer/composerAutocomplete.ts b/wework/src/components/chat/composer/composerAutocomplete.ts
index 480dd14aa1..58092f0861 100644
--- a/wework/src/components/chat/composer/composerAutocomplete.ts
+++ b/wework/src/components/chat/composer/composerAutocomplete.ts
@@ -31,7 +31,8 @@ export function findStandaloneTrigger(
value: string,
cursor: number,
trigger: '@' | '$' | '/',
- kind: ComposerTriggerKind
+ kind: ComposerTriggerKind,
+ allowWhitespaceInQuery?: (query: string) => boolean
): ComposerTextTrigger | null {
const beforeCursor = value.slice(0, cursor)
const triggerIndex = beforeCursor.lastIndexOf(trigger)
@@ -41,7 +42,7 @@ export function findStandaloneTrigger(
if (triggerIndex > 0 && !/\s/.test(previousChar)) return null
const query = value.slice(triggerIndex + 1, cursor)
- if (/\s/.test(query)) return null
+ if (/\s/.test(query) && !allowWhitespaceInQuery?.(query)) return null
if (trigger === '/' && query.includes('/')) return null
return { kind, start: triggerIndex, query }
@@ -57,6 +58,29 @@ export function chooseNearestTrigger(
)
}
+/**
+ * Parses the `@项目空间:keyword` / `@项目空间 keyword` scope syntax. Returns
+ * the keyword after the separator when the query starts with one of the given
+ * scope labels followed by a half-width/full-width colon or whitespace,
+ * otherwise null. Labels are matched longest-first so labels containing
+ * spaces (e.g. "project space") win over shorter prefixes.
+ */
+export function parseCloudProjectScopeQuery(query: string, scopeLabels: string[]): string | null {
+ const labels = scopeLabels
+ .map(label => label.trim().toLowerCase())
+ .filter(Boolean)
+ .sort((left, right) => right.length - left.length)
+ const normalizedQuery = query.toLowerCase()
+ for (const label of labels) {
+ if (!normalizedQuery.startsWith(label)) continue
+ const rest = query.slice(label.length)
+ const separatorMatch = rest.match(/^[::]|\s+/)
+ if (!separatorMatch) return null
+ return rest.slice(separatorMatch[0].length)
+ }
+ return null
+}
+
export function hasDraftTextForSlashCommands(value: string): boolean {
const trimmed = value.trim()
return trimmed.length > 0 && !SLASH_ONLY_PATTERN.test(value)
diff --git a/wework/src/components/chat/composer/composerMentionCandidates.test.ts b/wework/src/components/chat/composer/composerMentionCandidates.test.ts
index 215e10c390..b207163fb6 100644
--- a/wework/src/components/chat/composer/composerMentionCandidates.test.ts
+++ b/wework/src/components/chat/composer/composerMentionCandidates.test.ts
@@ -1,5 +1,38 @@
import { describe, expect, test } from 'vitest'
-import { appReference } from './composerMentionCandidates'
+import type { CloudProject } from '@/api/deliveries'
+import { parseCloudProjectScopeQuery } from './composerAutocomplete'
+import { appReference, matchesMentionQuery } from './composerMentionCandidates'
+import type { ComposerCloudMentionCandidate } from './composerMentionCandidates'
+
+const CLOUD_PROJECT: CloudProject = {
+ id: '7',
+ public_id: 'pub-7',
+ project_key: 'GW',
+ name: '官网改版',
+ description: '',
+ created_by_user_id: 1,
+ status: 'active',
+ version: 1,
+ created_at: '2026-07-01T00:00:00Z',
+ updated_at: '2026-07-01T00:00:00Z',
+}
+
+function cloudCandidate(overrides: Partial = {}) {
+ const candidate: ComposerCloudMentionCandidate = {
+ kind: 'cloud',
+ key: 'cloud-project-space:7',
+ title: '官网改版',
+ description: 'GW',
+ metaLabel: '云空间',
+ testId: 'cloud-project-space-7',
+ enabled: true,
+ reference: '[$项目空间:官网改版](cloud://projects/7)',
+ searchAliases: ['官网改版', 'GW', '项目空间', 'project space'],
+ project: CLOUD_PROJECT,
+ ...overrides,
+ }
+ return candidate
+}
describe('appReference', () => {
test('uses a generated Skill reference for Wegent connector apps', () => {
@@ -17,3 +50,54 @@ describe('appReference', () => {
expect(appReference({ id: 'calendar', name: 'Calendar' })).toBe('[$Calendar](app://calendar)')
})
})
+
+describe('matchesMentionQuery', () => {
+ test('matches every candidate for an empty query', () => {
+ expect(matchesMentionQuery(cloudCandidate(), '')).toBe(true)
+ expect(matchesMentionQuery(cloudCandidate(), ' ')).toBe(true)
+ })
+
+ test('matches against the title, description, and aliases case-insensitively', () => {
+ const candidate = cloudCandidate()
+ expect(matchesMentionQuery(candidate, '官')).toBe(true)
+ expect(matchesMentionQuery(candidate, 'gw')).toBe(true)
+ expect(matchesMentionQuery(candidate, 'PROJECT SPACE')).toBe(true)
+ expect(matchesMentionQuery(candidate, '移动端')).toBe(false)
+ })
+})
+
+describe('parseCloudProjectScopeQuery', () => {
+ const labels = ['项目空间', 'project space', 'project-space']
+
+ test('returns the keyword after a half-width or full-width colon', () => {
+ expect(parseCloudProjectScopeQuery('项目空间:官', labels)).toBe('官')
+ expect(parseCloudProjectScopeQuery('项目空间:官', labels)).toBe('官')
+ expect(parseCloudProjectScopeQuery('项目空间:', labels)).toBe('')
+ })
+
+ test('matches scope labels case-insensitively', () => {
+ expect(parseCloudProjectScopeQuery('Project-Space:web', labels)).toBe('web')
+ })
+
+ test('returns null when the head is not a scope label', () => {
+ expect(parseCloudProjectScopeQuery('云空间:官', labels)).toBeNull()
+ expect(parseCloudProjectScopeQuery('官网', labels)).toBeNull()
+ expect(parseCloudProjectScopeQuery(':官', labels)).toBeNull()
+ })
+
+ test('returns the keyword after whitespace, matching typed phrases', () => {
+ expect(parseCloudProjectScopeQuery('项目空间 新建项目', labels)).toBe('新建项目')
+ expect(parseCloudProjectScopeQuery('项目空间 ', labels)).toBe('')
+ expect(parseCloudProjectScopeQuery('项目空间 官', labels)).toBe('官')
+ })
+
+ test('prefers the longest label so labels with spaces parse correctly', () => {
+ expect(parseCloudProjectScopeQuery('project space web', labels)).toBe('web')
+ expect(parseCloudProjectScopeQuery('project-space new project', labels)).toBe('new project')
+ })
+
+ test('returns null for a bare scope label without a separator', () => {
+ expect(parseCloudProjectScopeQuery('项目空间', labels)).toBeNull()
+ expect(parseCloudProjectScopeQuery('项目空间abc', labels)).toBeNull()
+ })
+})
diff --git a/wework/src/components/chat/composer/composerMentionCandidates.ts b/wework/src/components/chat/composer/composerMentionCandidates.ts
index 29d1ab679e..47e00d5e53 100644
--- a/wework/src/components/chat/composer/composerMentionCandidates.ts
+++ b/wework/src/components/chat/composer/composerMentionCandidates.ts
@@ -1,6 +1,7 @@
import { useTranslation } from '@/hooks/useTranslation'
import { localSkillReference } from '@/lib/local-skill-reference'
import { getModelCompatibilityFamily, inferModelFamily } from '@/lib/model-ui'
+import type { CloudProject } from '@/api/deliveries'
import type { LocalDeviceApp, LocalDeviceSkill, UnifiedModel } from '@/types/api'
import { displaySkillNameFromName, localSkillTestId } from './composerMentions'
@@ -41,12 +42,25 @@ export type ComposerMentionCandidate =
enabled: boolean
reference: string
searchAliases: string[]
+ statusLabel?: string
+ project?: CloudProject
}
export type ComposerSkillMentionCandidate = Extract
export type ComposerAppMentionCandidate = Extract
export type ComposerCloudMentionCandidate = Extract
+export function matchesMentionQuery(candidate: ComposerMentionCandidate, query: string): boolean {
+ const normalizedQuery = query.trim().toLowerCase()
+ if (!normalizedQuery) return true
+ const description = candidate.description || ''
+ return (
+ candidate.title.toLowerCase().includes(normalizedQuery) ||
+ description.toLowerCase().includes(normalizedQuery) ||
+ candidate.searchAliases.some(alias => alias.toLowerCase().includes(normalizedQuery))
+ )
+}
+
export function displaySkillName(skill: LocalDeviceSkill): string {
return displaySkillNameFromName(skill.name)
}
diff --git a/wework/src/components/chat/composer/composerTextareaTypes.ts b/wework/src/components/chat/composer/composerTextareaTypes.ts
index ce0960e019..17e8b316da 100644
--- a/wework/src/components/chat/composer/composerTextareaTypes.ts
+++ b/wework/src/components/chat/composer/composerTextareaTypes.ts
@@ -1,4 +1,5 @@
import type { RefObject } from 'react'
+import type { CloudProject } from '@/api/deliveries'
import type { LocalDeviceApp, LocalDeviceSkill, ModelOptions, UnifiedModel } from '@/types/api'
import type { WorkspaceFileApi, WorkspaceTarget } from '@/types/workspace-files'
import type { ComposerCloudMentionCandidate } from './composerMentionCandidates'
@@ -25,6 +26,9 @@ export interface ComposerTextareaProps {
workspaceTarget?: WorkspaceTarget | null
workspaceFileApi?: WorkspaceFileApi
cloudMentionCandidates?: ComposerCloudMentionCandidate[]
+ cloudProjectCandidates?: ComposerCloudMentionCandidate[]
+ cloudSpaceEnabled?: boolean
+ onSelectCloudProject?: (project: CloudProject) => void
onListLocalSkills?: () => Promise
onListLocalApps?: () => Promise
models?: UnifiedModel[]
diff --git a/wework/src/components/chat/composer/useComposerMentionCandidates.ts b/wework/src/components/chat/composer/useComposerMentionCandidates.ts
index 8642776948..5f8d290d2d 100644
--- a/wework/src/components/chat/composer/useComposerMentionCandidates.ts
+++ b/wework/src/components/chat/composer/useComposerMentionCandidates.ts
@@ -8,6 +8,7 @@ import {
displayAppName,
displaySkillName,
displaySkillSource,
+ matchesMentionQuery,
skillReference,
type ComposerAppMentionCandidate,
type ComposerCloudMentionCandidate,
@@ -65,18 +66,10 @@ export function useComposerMentionCandidates(
() => [...cloudCandidates, ...skillCandidates, ...appCandidates],
[appCandidates, cloudCandidates, skillCandidates]
)
- const filteredMentionCandidates = useMemo(() => {
- const normalizedQuery = query.trim().toLowerCase()
- if (!normalizedQuery) return mentionCandidates
- return mentionCandidates.filter(candidate => {
- const description = candidate.description || ''
- return (
- candidate.title.toLowerCase().includes(normalizedQuery) ||
- description.toLowerCase().includes(normalizedQuery) ||
- candidate.searchAliases.some(alias => alias.toLowerCase().includes(normalizedQuery))
- )
- })
- }, [mentionCandidates, query])
+ const filteredMentionCandidates = useMemo(
+ () => mentionCandidates.filter(candidate => matchesMentionQuery(candidate, query)),
+ [mentionCandidates, query]
+ )
return { appCandidates, skillCandidates, mentionCandidates, filteredMentionCandidates }
}
diff --git a/wework/src/components/layout/DesktopWorkbenchMain.tsx b/wework/src/components/layout/DesktopWorkbenchMain.tsx
index 801c07a30e..1789c2a7af 100644
--- a/wework/src/components/layout/DesktopWorkbenchMain.tsx
+++ b/wework/src/components/layout/DesktopWorkbenchMain.tsx
@@ -137,6 +137,25 @@ const COLLAPSED_RIGHT_TITLEBAR_ACTIONS_CLEARANCE = '5rem'
const TEMPORARY_CHAT_PANEL_DEFAULT_WIDTH = 420
const MACOS_TRAFFIC_LIGHTS_CLEARANCE_CLASS = 'pl-[92px]'
const BLANK_BROWSER_MIGRATION_TTL_MS = 2 * 60 * 1000
+
+function cloudLoopItemStatusLabel(
+ status: CloudLoopItem['status'],
+ t: ReturnType['t']
+): string {
+ switch (status) {
+ case 'inbox':
+ return t('workbench.cloud_todo_status_inbox', '收集箱')
+ case 'pending':
+ return t('workbench.cloud_todo_status_pending', '待处理')
+ case 'in_progress':
+ return t('workbench.cloud_todo_status_in_progress', '进行中')
+ case 'in_review':
+ return t('workbench.cloud_todo_status_in_review', '待评审')
+ case 'completed':
+ return t('workbench.cloud_todo_status_completed', '已完成')
+ }
+}
+
function cloudItemAsLocalWorkItem(
item: CloudLoopItem,
runtimeTask: RuntimeTaskAddress
@@ -513,6 +532,8 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({
pendingProjectForTask(currentRuntimeTask)
)
const [todoBindingError, setTodoBindingError] = useState(null)
+ const [cloudProjects, setCloudProjects] = useState([])
+ const [cloudActionNotice, setCloudActionNotice] = useState(null)
const [cloudMentionState, setCloudMentionState] = useState<{
todoId: string
candidates: ComposerCloudMentionCandidate[]
@@ -547,7 +568,7 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({
value: [
...scope.filter((line): line is string => Boolean(line)),
'When the user refers to “this project” or “this task”, use this current cloud context.',
- 'Use the wegent-delivery MCP tools to inspect task details, shared files, and deliveries when needed. Do not ask for an id that is already provided here.',
+ 'Use the wegent_delivery MCP tools to inspect task details, shared files, and deliveries when needed. Do not ask for an id that is already provided here.',
].join('\n'),
},
}
@@ -687,52 +708,55 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({
title: string,
description: string,
reference: string,
- aliases: string[]
+ aliases: string[],
+ statusLabel?: string
): ComposerCloudMentionCandidate => ({
kind: 'cloud',
key,
title,
description,
- metaLabel: '云空间',
+ metaLabel: t('workbench.mention_cloud_space', '云空间'),
testId: key.replace(/[^a-zA-Z0-9_-]/g, '-'),
enabled: true,
- reference: `[$${title}](${reference})`,
+ reference,
searchAliases: aliases,
+ statusLabel,
})
setCloudMentionState({
todoId: composerTodoItem?.id ?? `project:${projectId}`,
candidates: [
candidate(
`cloud-project:${projectId}`,
- '云空间',
- '当前云项目的共享内容',
- `cloud://projects/${projectId}`,
+ t('workbench.mention_cloud_whole_space', '整个空间'),
+ t('workbench.mention_cloud_whole_space_description', '共享文件 + 看板全部内容'),
+ `[$${t('workbench.mention_cloud_whole_space', '整个空间')}](cloud://projects/${projectId})`,
['云项目', 'cloud', 'workspace']
),
+ ...items.items.map(item =>
+ candidate(
+ `cloud-todo:${item.id}`,
+ item.id,
+ item.title,
+ `[$${t('workbench.mention_cloud_todo_chip', '任务')}:${item.id}](cloud://projects/${projectId}/todos/${item.id})`,
+ [item.title, item.status, 'TODO', '任务'],
+ cloudLoopItemStatusLabel(item.status, t)
+ )
+ ),
...files.items.map(file =>
candidate(
`cloud-file:${file.id}`,
file.name,
file.path,
- `cloud://projects/${projectId}/files/${file.id}`,
+ `[$${file.name}](cloud://projects/${projectId}/files/${file.id})`,
[file.path, file.kind, '文件', '目录']
)
),
- ...items.items.map(item =>
- candidate(
- `cloud-todo:${item.id}`,
- item.id,
- item.title,
- `cloud://projects/${projectId}/todos/${item.id}`,
- [item.title, item.status, 'TODO', '任务']
- )
- ),
...deliveries.items.map(delivery =>
candidate(
`cloud-delivery:${delivery.id}`,
`交付 ${delivery.id.slice(0, 8)}`,
delivery.delivered_at ?? delivery.created_at,
- `cloud://projects/${projectId}/deliveries/${delivery.id}`,
+ `[$交付 ${delivery.id.slice(0, 8)}](cloud://projects/${projectId}/deliveries/${delivery.id})`,
['交付', 'delivery', delivery.id]
)
),
@@ -745,13 +769,101 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({
return () => {
active = false
}
- }, [composerCloudProject, composerTodoItem, services?.deliveryApi])
+ }, [composerCloudProject, composerTodoItem, services?.deliveryApi, t])
const visibleCloudMentionCandidates =
composerCloudProject &&
cloudMentionState?.todoId === (composerTodoItem?.id ?? `project:${composerCloudProject.id}`)
? cloudMentionState.candidates
: []
+ // Accessible cloud projects power the @ 项目空间 entry even when the current
+ // session is not bound to a cloud project yet.
+ useEffect(() => {
+ let active = true
+ const api = services?.deliveryApi
+ if (!api) {
+ queueMicrotask(() => {
+ if (active) setCloudProjects([])
+ })
+ return () => {
+ active = false
+ }
+ }
+ void api
+ .listCloudProjects()
+ .then(result => {
+ if (active) setCloudProjects(result.items)
+ })
+ .catch(() => {
+ if (active) setCloudProjects([])
+ })
+ return () => {
+ active = false
+ }
+ }, [services?.deliveryApi])
+ const cloudProjectMentionCandidates = useMemo(
+ () =>
+ cloudProjects.map(project => {
+ const spaceLabel = t('workbench.mention_cloud_project_space', '项目空间')
+ return {
+ kind: 'cloud',
+ key: `cloud-project-space:${project.id}`,
+ title: project.name,
+ description: project.description || project.project_key || undefined,
+ metaLabel: t('workbench.mention_cloud_space', '云空间'),
+ testId: `cloud-project-space-${String(project.id).replace(/[^a-zA-Z0-9_-]/g, '-')}`,
+ enabled: true,
+ reference: `[$${spaceLabel}:${project.name}](cloud://projects/${project.id})`,
+ searchAliases: [
+ project.name,
+ project.project_key,
+ project.description,
+ spaceLabel,
+ 'project space',
+ 'project-space',
+ 'cloud',
+ ].filter(alias => Boolean(alias)),
+ project,
+ }
+ }),
+ [cloudProjects, t]
+ )
+ const bindComposerCloudProject = useCallback(
+ (project: CloudProject, notice: string) => {
+ setCloudActionNotice(notice)
+ if (!currentRuntimeTask) {
+ setPendingCloudContext(project, null)
+ return
+ }
+ const api = services?.deliveryApi
+ if (!api) return
+ void api
+ .bindProjectTask(project.id, currentRuntimeTask, runtimeTaskTitle)
+ .then(() => {
+ setBoundCloudProject(project)
+ setBoundCloudItem(null)
+ setDeliveryItem(null)
+ })
+ .catch(cause => {
+ setTodoBindingError(
+ cause instanceof Error
+ ? cause.message
+ : t('workbench.cloud_project_bind_failed', '关联项目空间失败')
+ )
+ })
+ },
+ [currentRuntimeTask, runtimeTaskTitle, services?.deliveryApi, setPendingCloudContext, t]
+ )
+ const handleSelectCloudProject = useCallback(
+ (project: CloudProject) => {
+ bindComposerCloudProject(
+ project,
+ t('workbench.cloud_project_bound_notice', { name: project.name })
+ )
+ },
+ [bindComposerCloudProject, t]
+ )
+
const activeDeliveryItem =
currentRuntimeTask &&
deliveryItem?.runtimeRefs.some(
@@ -2169,6 +2281,9 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({
guidanceMessages={paneGuidanceMessages}
codeComments={paneSession.codeCommentContexts}
cloudMentionCandidates={visibleCloudMentionCandidates}
+ cloudProjectCandidates={cloudProjectMentionCandidates}
+ cloudSpaceEnabled={Boolean(services?.deliveryApi)}
+ onSelectCloudProject={handleSelectCloudProject}
isStreaming={paneIsBusy}
onPause={pauseCurrentResponse}
onCompactContext={compactCurrentContext}
@@ -2300,6 +2415,9 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({
guidanceMessages={paneGuidanceMessages}
codeComments={paneSession.codeCommentContexts}
cloudMentionCandidates={visibleCloudMentionCandidates}
+ cloudProjectCandidates={cloudProjectMentionCandidates}
+ cloudSpaceEnabled={Boolean(services?.deliveryApi)}
+ onSelectCloudProject={handleSelectCloudProject}
isStreaming={paneIsBusy}
onPause={pauseCurrentResponse}
onCompactContext={compactCurrentContext}
@@ -2499,6 +2617,7 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({
tone="error"
onClear={() => setTodoBindingError(null)}
/>
+ setCloudActionNotice(null)} />
{deliveryDialogOpen &&
activeDeliveryItem &&
currentRuntimeTask &&
diff --git a/wework/src/features/todo/CloudFilesView.tsx b/wework/src/features/todo/CloudFilesView.tsx
index e17a8cb0d8..e9737d25eb 100644
--- a/wework/src/features/todo/CloudFilesView.tsx
+++ b/wework/src/features/todo/CloudFilesView.tsx
@@ -106,217 +106,221 @@ export function CloudFilesView({ api, project }: { api: DeliveryApi; project: Cl
}
return (
-
-
-
-
共享文件
-
- 成员和 AI 可通过权限控制的云空间访问这些内容。
-
-
-
-
{
- const selected = [...(event.target.files ?? [])]
- void uploadFiles(selected)
- }}
- />
-
-
-
- {creatingFolder && (
-
+
+
+
+
+
共享文件
+
+ 成员和 AI 可通过权限控制的云空间访问这些内容。
+
+
+
setFolderName(event.target.value)}
- onKeyDown={event => event.key === 'Enter' && void createFolder()}
- placeholder="文件夹路径,例如 docs/design"
- className="h-8 min-w-0 flex-1 rounded-md border border-border px-3 text-sm outline-none focus:border-focus"
+ ref={inputRef}
+ type="file"
+ multiple
+ className="hidden"
+ onChange={event => {
+ const selected = [...(event.target.files ?? [])]
+ void uploadFiles(selected)
+ }}
/>
- )}
- {error && (
-
- {error}
-
- )}
-
-
- 名称
- 类型
- 更新时间
- 大小
-
-
- {files.length === 0 ? (
-
- 暂无共享文件
-
- ) : (
- files.map(entry => (
-
- ))
+ 创建
+
+
+
)}
-
-
-
-
交付快照
- 来自已完成任务,只读且不可修改
-
-
-
-
任务
+ {error && (
+
+ {error}
+
+ )}
+
+
名称
类型
- 交付时间
+ 更新时间
大小
- {deliveryFiles.length === 0 ? (
-
- 暂无交付文件
+ {files.length === 0 ? (
+
+ 暂无共享文件
) : (
- deliveryFiles.map(entry => (
+ files.map(entry => (
))
)}
-
-
- 在 Wework 输入框中输入 @,即可让 AI 查看云项目、目录、任务或交付。
-
+
+
+
交付快照
+ 来自已完成任务,只读且不可修改
+
+
+
+ 任务
+ 名称
+ 类型
+ 交付时间
+ 大小
+
+
+ {deliveryFiles.length === 0 ? (
+
+ 暂无交付文件
+
+ ) : (
+ deliveryFiles.map(entry => (
+
+
+ {entry.loop_item_id}
+ {entry.loop_item_title}
+
+
+
+
+ {entry.relative_path}
+
+
+ {entry.content_type || '文件'}
+ {entry.delivered_at.slice(0, 10)}
+ {entry.size_bytes} B
+
+
+ ))
+ )}
+
+
+
+ 在 Wework 输入框中输入 @,即可让 AI 查看云项目、目录、任务或交付。
+
+
)
}
diff --git a/wework/src/features/todo/CloudMyWorkCalendar.tsx b/wework/src/features/todo/CloudMyWorkCalendar.tsx
new file mode 100644
index 0000000000..ce760c8326
--- /dev/null
+++ b/wework/src/features/todo/CloudMyWorkCalendar.tsx
@@ -0,0 +1,88 @@
+import { useMemo } from 'react'
+import FullCalendar from '@fullcalendar/react'
+import dayGridPlugin from '@fullcalendar/daygrid'
+import zhCnLocale from '@fullcalendar/core/locales/zh-cn'
+import type { CloudMyWorkItem } from '@/api/deliveries'
+import { useTranslation } from '@/hooks/useTranslation'
+import { myWorkGroupOf, type MyWorkGroupKey } from './cloudMyWorkModel'
+import './cloud-my-work-calendar.css'
+
+interface CloudMyWorkCalendarProps {
+ items: CloudMyWorkItem[]
+ onSelectItem: (item: CloudMyWorkItem) => void
+}
+
+// Status colors mirror the group dot classes used across the my-work views.
+const GROUP_EVENT_COLORS: Record
= {
+ action: '#6366f1',
+ running: '#f59e0b',
+ review: '#8b5cf6',
+ done: '#10b981',
+}
+
+export function CloudMyWorkCalendar({ items, onSelectItem }: CloudMyWorkCalendarProps) {
+ const { t, i18n } = useTranslation('common')
+
+ const datedItems = useMemo(
+ () =>
+ items.filter(item => {
+ if (!item.due_at) return false
+ return !Number.isNaN(new Date(item.due_at).getTime())
+ }),
+ [items]
+ )
+
+ const events = useMemo(
+ () =>
+ datedItems.map(item => ({
+ id: item.id,
+ title: item.title,
+ start: item.due_at as string,
+ allDay: true,
+ backgroundColor: GROUP_EVENT_COLORS[myWorkGroupOf(item)],
+ borderColor: 'transparent',
+ extendedProps: { item },
+ })),
+ [datedItems]
+ )
+
+ return (
+
+
+ (
+
+ {arg.event.id}
+ {arg.event.title}
+
+ )}
+ eventClick={info => {
+ const item = info.event.extendedProps.item as CloudMyWorkItem | undefined
+ if (item) onSelectItem(item)
+ }}
+ />
+
+
+ {t('todo.my_work_calendar_note', '日历仅展示设置了截止日期的任务。')}
+
+
+ )
+}
diff --git a/wework/src/features/todo/CloudMyWorkView.test.tsx b/wework/src/features/todo/CloudMyWorkView.test.tsx
new file mode 100644
index 0000000000..2888a600ab
--- /dev/null
+++ b/wework/src/features/todo/CloudMyWorkView.test.tsx
@@ -0,0 +1,140 @@
+import { render, screen, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it, vi } from 'vitest'
+import type { CloudMyWorkItem } from '@/api/deliveries'
+import { CloudMyWorkView } from './CloudMyWorkView'
+
+function dueAt(offsetDays: number): string {
+ const date = new Date()
+ date.setDate(date.getDate() + offsetDays)
+ date.setHours(12, 0, 0, 0)
+ return date.toISOString()
+}
+
+function makeItem(overrides: Partial): CloudMyWorkItem {
+ return {
+ id: 'WEG-1',
+ cloud_project_id: 1,
+ sequence_number: 1,
+ parent_id: null,
+ created_by_user_id: 1,
+ assignee_user_id: 1,
+ title: 'Cloud TODO',
+ description: '',
+ status: 'inbox',
+ priority: 'none',
+ due_at: null,
+ sort_order: 0,
+ current_delivery_id: null,
+ version: 1,
+ created_at: '2026-07-22T00:00:00Z',
+ updated_at: '2026-07-22T00:00:00Z',
+ completed_at: null,
+ project_key: 'WEG',
+ project_name: 'Wegent',
+ has_active_task: false,
+ ...overrides,
+ }
+}
+
+const items: CloudMyWorkItem[] = [
+ makeItem({
+ id: 'WEG-1',
+ title: '需要处理的任务',
+ status: 'pending',
+ priority: 'high',
+ due_at: dueAt(0),
+ }),
+ makeItem({
+ id: 'WEG-2',
+ title: '执行中的任务',
+ status: 'in_progress',
+ has_active_task: true,
+ priority: 'medium',
+ due_at: dueAt(1),
+ }),
+ makeItem({
+ id: 'WEG-3',
+ title: '待确认的任务',
+ status: 'in_review',
+ priority: 'low',
+ due_at: dueAt(2),
+ }),
+ makeItem({ id: 'WEG-4', title: '已完成的任务', status: 'completed', due_at: dueAt(-1) }),
+ makeItem({ id: 'WEG-5', title: '无截止的任务', status: 'pending' }),
+]
+
+function renderView(onSelectItem = vi.fn()) {
+ render()
+ return onSelectItem
+}
+
+describe('CloudMyWorkView', () => {
+ it('renders the grouped view with the original my-work grouping semantics', () => {
+ renderView()
+ const groups = screen.getByTestId('my-work-groups')
+ expect(within(groups).getByText('需要我处理')).toBeInTheDocument()
+ expect(within(groups).getByText('正在执行')).toBeInTheDocument()
+ expect(within(groups).getByText('等待确认')).toBeInTheDocument()
+ expect(within(groups).getByText('已完成')).toBeInTheDocument()
+ // 需要我处理: not completed and no active task (WEG-1, WEG-3, WEG-5)
+ expect(screen.getByTestId('my-work-group-action-WEG-1')).toBeInTheDocument()
+ expect(screen.getByTestId('my-work-group-action-WEG-3')).toBeInTheDocument()
+ expect(screen.getByTestId('my-work-group-action-WEG-5')).toBeInTheDocument()
+ expect(screen.getByTestId('my-work-group-running-WEG-2')).toBeInTheDocument()
+ expect(screen.getByTestId('my-work-group-review-WEG-3')).toBeInTheDocument()
+ expect(screen.getByTestId('my-work-group-done-WEG-4')).toBeInTheDocument()
+ })
+
+ it('selecting an item in the grouped view notifies the parent', async () => {
+ const onSelectItem = renderView()
+ await userEvent.click(screen.getByTestId('my-work-group-action-WEG-1'))
+ expect(onSelectItem).toHaveBeenCalledWith(items[0])
+ })
+
+ it('switches to the list view sorted by due date', async () => {
+ const onSelectItem = renderView()
+ await userEvent.click(screen.getByTestId('my-work-view-tab-list'))
+ const list = screen.getByTestId('my-work-list')
+ const rows = within(list)
+ .getAllByRole('button')
+ .map(row => row.getAttribute('data-testid'))
+ expect(rows).toEqual([
+ 'my-work-list-row-WEG-4',
+ 'my-work-list-row-WEG-1',
+ 'my-work-list-row-WEG-2',
+ 'my-work-list-row-WEG-3',
+ 'my-work-list-row-WEG-5',
+ ])
+ await userEvent.click(screen.getByTestId('my-work-list-row-WEG-2'))
+ expect(onSelectItem).toHaveBeenCalledWith(items[1])
+ })
+
+ it('switches to the timeline view grouped by due day', async () => {
+ renderView()
+ await userEvent.click(screen.getByTestId('my-work-view-tab-timeline'))
+ const timeline = screen.getByTestId('my-work-timeline')
+ expect(within(timeline).getByText(/^今天 · /)).toBeInTheDocument()
+ expect(within(timeline).getByText(/^明天 · /)).toBeInTheDocument()
+ expect(within(timeline).getByText('无截止日期')).toBeInTheDocument()
+ expect(screen.getByTestId('my-work-timeline-item-WEG-5')).toBeInTheDocument()
+ })
+
+ it('switches to the calendar view showing dated tasks and forwards clicks', async () => {
+ const onSelectItem = renderView()
+ await userEvent.click(screen.getByTestId('my-work-view-tab-calendar'))
+ const calendar = await screen.findByTestId('my-work-calendar')
+ expect(await within(calendar).findByText('需要处理的任务')).toBeInTheDocument()
+ expect(within(calendar).queryByText('无截止的任务')).not.toBeInTheDocument()
+ await userEvent.click(within(calendar).getByText('需要处理的任务'))
+ expect(onSelectItem).toHaveBeenCalledWith(items[0])
+ })
+
+ it('marks the active tab as selected', async () => {
+ renderView()
+ expect(screen.getByTestId('my-work-view-tab-group')).toHaveAttribute('aria-selected', 'true')
+ await userEvent.click(screen.getByTestId('my-work-view-tab-timeline'))
+ expect(screen.getByTestId('my-work-view-tab-timeline')).toHaveAttribute('aria-selected', 'true')
+ expect(screen.getByTestId('my-work-view-tab-group')).toHaveAttribute('aria-selected', 'false')
+ })
+})
diff --git a/wework/src/features/todo/CloudMyWorkView.tsx b/wework/src/features/todo/CloudMyWorkView.tsx
new file mode 100644
index 0000000000..be3d57790a
--- /dev/null
+++ b/wework/src/features/todo/CloudMyWorkView.tsx
@@ -0,0 +1,390 @@
+import { useMemo, useState } from 'react'
+import { CalendarDays, Clock, LayoutGrid, List } from 'lucide-react'
+import type { CloudMyWorkItem } from '@/api/deliveries'
+import { useTranslation } from '@/hooks/useTranslation'
+import { cn } from '@/lib/utils'
+import { CloudMyWorkCalendar } from './CloudMyWorkCalendar'
+import { myWorkGroupOf, type MyWorkGroupKey } from './cloudMyWorkModel'
+
+export type MyWorkView = 'group' | 'list' | 'calendar' | 'timeline'
+
+interface CloudMyWorkViewProps {
+ items: CloudMyWorkItem[]
+ onSelectItem: (item: CloudMyWorkItem) => void
+}
+
+const GROUP_ORDER: MyWorkGroupKey[] = ['action', 'running', 'review', 'done']
+
+const GROUP_META: Record =
+ {
+ action: {
+ dotClass: 'bg-indigo-500',
+ labelKey: 'todo.needs_my_action',
+ fallback: '需要我处理',
+ },
+ running: {
+ dotClass: 'bg-amber-500',
+ labelKey: 'todo.my_work_running',
+ fallback: '正在执行',
+ },
+ review: {
+ dotClass: 'bg-violet-500',
+ labelKey: 'todo.waiting_confirmation',
+ fallback: '等待确认',
+ },
+ done: {
+ dotClass: 'bg-emerald-500',
+ labelKey: 'todo.state_completed',
+ fallback: '已完成',
+ },
+ }
+
+// Grouped view filters replicate the original my-work grouping semantics; an
+// item may appear in more than one group (e.g. in-review without an active
+// task shows under both "需要我处理" and "等待确认").
+const GROUP_FILTERS: Record boolean> = {
+ action: item => !item.has_active_task && item.status !== 'completed',
+ running: item => item.has_active_task && item.status === 'in_progress',
+ review: item => item.status === 'in_review',
+ done: item => item.status === 'completed',
+}
+
+const PRIORITY_ORDER: Record = {
+ urgent: 0,
+ high: 1,
+ medium: 2,
+ low: 3,
+ none: 4,
+}
+
+const PRIORITY_LABEL_KEYS: Record = {
+ urgent: ['todo.priority_urgent', '紧急'],
+ high: ['todo.priority_high', '高'],
+ medium: ['todo.priority_normal', '普通'],
+ low: ['todo.priority_low', '低'],
+ none: ['todo.priority_none_short', '无'],
+}
+
+function startOfDay(date: Date): Date {
+ const copy = new Date(date)
+ copy.setHours(0, 0, 0, 0)
+ return copy
+}
+
+function dueDayOf(item: CloudMyWorkItem): Date | null {
+ if (!item.due_at) return null
+ const parsed = new Date(item.due_at)
+ return Number.isNaN(parsed.getTime()) ? null : startOfDay(parsed)
+}
+
+function dayDiffFromToday(day: Date): number {
+ const today = startOfDay(new Date())
+ return Math.round((day.getTime() - today.getTime()) / 86400000)
+}
+
+function compareItems(a: CloudMyWorkItem, b: CloudMyWorkItem): number {
+ const dayA = dueDayOf(a)
+ const dayB = dueDayOf(b)
+ if (dayA && dayB && dayA.getTime() !== dayB.getTime()) return dayA.getTime() - dayB.getTime()
+ if (dayA && !dayB) return -1
+ if (!dayA && dayB) return 1
+ return PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]
+}
+
+interface GroupSectionProps {
+ groupKey: MyWorkGroupKey
+ items: CloudMyWorkItem[]
+ onSelectItem: (item: CloudMyWorkItem) => void
+}
+
+function GroupSection({ groupKey, items, onSelectItem }: GroupSectionProps) {
+ const { t } = useTranslation('common')
+ const meta = GROUP_META[groupKey]
+ return (
+
+
+
+ {t(meta.labelKey, meta.fallback)}
+ {items.length}
+
+
+ {items.map(item => (
+
+ ))}
+
+
+ )
+}
+
+function ListView({
+ items,
+ onSelectItem,
+}: { items: CloudMyWorkItem[] } & Pick) {
+ const { t, i18n } = useTranslation('common')
+ const sorted = useMemo(() => [...items].sort(compareItems), [items])
+ const dateFormatter = useMemo(
+ () => new Intl.DateTimeFormat(i18n.language, { month: 'short', day: 'numeric' }),
+ [i18n.language]
+ )
+ return (
+
+
+ {t('todo.my_work_col_task', '任务')}
+ {t('todo.my_work_col_project', '项目')}
+ {t('todo.status', '状态')}
+ {t('todo.priority', '优先级')}
+ {t('todo.my_work_col_due', '截止日期')}
+
+
+ {sorted.map(item => {
+ const group = myWorkGroupOf(item)
+ const due = dueDayOf(item)
+ const [priorityKey, priorityFallback] = PRIORITY_LABEL_KEYS[item.priority]
+ return (
+
+ )
+ })}
+
+ {sorted.length === 0 && (
+
+ {t('todo.no_items_in_group', '当前没有事项')}
+
+ )}
+
+ )
+}
+
+function TimelineView({
+ items,
+ onSelectItem,
+}: { items: CloudMyWorkItem[] } & Pick) {
+ const { t, i18n } = useTranslation('common')
+ const dateFormatter = useMemo(
+ () =>
+ new Intl.DateTimeFormat(i18n.language, {
+ month: 'short',
+ day: 'numeric',
+ weekday: 'short',
+ }),
+ [i18n.language]
+ )
+ const days = useMemo(() => {
+ const byDay = new Map()
+ for (const item of [...items].sort(compareItems)) {
+ const day = dueDayOf(item)
+ const key = day ? String(day.getTime()) : 'none'
+ const bucket = byDay.get(key) ?? { day, entries: [] }
+ bucket.entries.push(item)
+ byDay.set(key, bucket)
+ }
+ return [...byDay.entries()]
+ .sort(([keyA], [keyB]) => {
+ if (keyA === 'none') return 1
+ if (keyB === 'none') return -1
+ return Number(keyA) - Number(keyB)
+ })
+ .map(([, bucket]) => bucket)
+ }, [items])
+
+ function dayLabel(day: Date): string {
+ const diff = dayDiffFromToday(day)
+ const relative =
+ diff === 0
+ ? t('todo.my_work_today', '今天')
+ : diff === 1
+ ? t('todo.my_work_tomorrow', '明天')
+ : diff === -1
+ ? t('todo.my_work_yesterday', '昨天')
+ : null
+ const formatted = dateFormatter.format(day)
+ return relative ? `${relative} · ${formatted}` : formatted
+ }
+
+ return (
+
+ {days.map((bucket, index) => (
+
+ {index < days.length - 1 && (
+
+ )}
+
+ {bucket.day ? dayLabel(bucket.day) : t('todo.my_work_no_due_date', '无截止日期')}
+
+
+ {bucket.entries.map(item => {
+ const group = myWorkGroupOf(item)
+ const [priorityKey, priorityFallback] = PRIORITY_LABEL_KEYS[item.priority]
+ return (
+
+
+
+
+ )
+ })}
+
+
+ ))}
+ {days.length === 0 && (
+
+ {t('todo.no_items_in_group', '当前没有事项')}
+
+ )}
+
+ )
+}
+
+const VIEW_TABS: Array<{
+ key: MyWorkView
+ icon: typeof LayoutGrid
+ labelKey: string
+ fallback: string
+}> = [
+ { key: 'group', icon: LayoutGrid, labelKey: 'todo.my_work_view_group', fallback: '分组' },
+ { key: 'list', icon: List, labelKey: 'todo.my_work_view_list', fallback: '列表' },
+ { key: 'calendar', icon: CalendarDays, labelKey: 'todo.my_work_view_calendar', fallback: '日历' },
+ { key: 'timeline', icon: Clock, labelKey: 'todo.my_work_view_timeline', fallback: '时间线' },
+]
+
+export function CloudMyWorkView({ items, onSelectItem }: CloudMyWorkViewProps) {
+ const { t } = useTranslation('common')
+ const [view, setView] = useState('group')
+
+ return (
+
+
+
+
{t('todo.my_work', '我的工作')}
+
+ {t('todo.my_work_subtitle', '跨项目空间查看需要你处理的任务和本地执行。')}
+
+
+ {view === 'group' && (
+
+ {GROUP_ORDER.map(groupKey => (
+
+ ))}
+
+ )}
+ {view === 'list' &&
}
+ {view === 'calendar' && (
+
+ )}
+ {view === 'timeline' &&
}
+
+
+ {t(
+ 'todo.my_work_scope_note',
+ '这里只汇总与你相关的项目任务;未关联任务的普通本地会话不会出现。'
+ )}
+
+
+
+
+
+
+ {VIEW_TABS.map(tab => {
+ const Icon = tab.icon
+ const active = view === tab.key
+ return (
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/wework/src/features/todo/CloudProjectManageView.tsx b/wework/src/features/todo/CloudProjectManageView.tsx
new file mode 100644
index 0000000000..4506942429
--- /dev/null
+++ b/wework/src/features/todo/CloudProjectManageView.tsx
@@ -0,0 +1,443 @@
+import { useEffect, useState } from 'react'
+import { Check, Pencil, Search, Tag, Trash2, X } from 'lucide-react'
+import type {
+ CloudLoopItem,
+ CloudProject,
+ CloudProjectMember,
+ CloudUserSearchItem,
+} from '@/api/deliveries'
+import type { WorkbenchServices } from '@/features/workbench/workbenchServices'
+import { cn } from '@/lib/utils'
+
+type DeliveryApi = NonNullable
+
+const memberAvatarClasses = [
+ 'bg-gradient-to-br from-indigo-400 to-indigo-500',
+ 'bg-gradient-to-br from-emerald-400 to-emerald-500',
+ 'bg-gradient-to-br from-amber-400 to-amber-500',
+]
+
+export function CloudProjectManageView({
+ api,
+ project,
+ onProjectUpdated,
+}: {
+ api: DeliveryApi
+ project: CloudProject
+ onProjectUpdated?: (project: CloudProject) => void
+}) {
+ const [members, setMembers] = useState([])
+ const [query, setQuery] = useState('')
+ const [results, setResults] = useState([])
+ const [role, setRole] = useState('Developer')
+ const [savingUserId, setSavingUserId] = useState(null)
+ const [error, setError] = useState(null)
+ const [items, setItems] = useState([])
+ const [registryTags, setRegistryTags] = useState(project.tags ?? [])
+ const [projectVersion, setProjectVersion] = useState(project.version)
+ const [newTag, setNewTag] = useState('')
+ const [renamingTag, setRenamingTag] = useState(null)
+ const [renameValue, setRenameValue] = useState('')
+ const [tagBusy, setTagBusy] = useState(false)
+
+ useEffect(() => {
+ let active = true
+ void api
+ .listCloudProjectMembers(project.id)
+ .then(value => active && setMembers(value))
+ .catch(cause => active && setError(cause instanceof Error ? cause.message : '加载成员失败'))
+ void api
+ .listLoopItems(project.id)
+ .then(response => active && setItems(response.items))
+ .catch(() => {})
+ return () => {
+ active = false
+ }
+ }, [api, project.id])
+
+ // Tags shown in the manager: project registry plus tags already on items.
+ const allTags = Array.from(
+ new Set([...registryTags, ...items.flatMap(item => item.tags ?? [])])
+ ).sort((a, b) => a.localeCompare(b, 'zh-CN'))
+ const tagCounts = new Map()
+ for (const item of items) {
+ for (const tag of item.tags ?? []) {
+ tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1)
+ }
+ }
+
+ async function persistRegistry(tags: string[]) {
+ const updated = await api.updateCloudProject(project.id, { version: projectVersion, tags })
+ setRegistryTags(updated.tags ?? [])
+ setProjectVersion(updated.version)
+ onProjectUpdated?.(updated)
+ }
+
+ async function createTag() {
+ const tag = newTag.trim()
+ if (!tag || tagBusy) return
+ setTagBusy(true)
+ setError(null)
+ try {
+ if (!allTags.includes(tag)) await persistRegistry([...registryTags, tag])
+ setNewTag('')
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : '新建标签失败')
+ } finally {
+ setTagBusy(false)
+ }
+ }
+
+ async function renameTag(oldTag: string) {
+ const nextTag = renameValue.trim()
+ setRenamingTag(null)
+ if (!nextTag || nextTag === oldTag || tagBusy) return
+ if (allTags.includes(nextTag)) {
+ setError(`标签“${nextTag}”已存在`)
+ return
+ }
+ setTagBusy(true)
+ setError(null)
+ try {
+ const affected = items.filter(item => (item.tags ?? []).includes(oldTag))
+ const renamed = await Promise.all(
+ affected.map(item =>
+ api.updateLoopItem(item.id, {
+ version: item.version,
+ tags: (item.tags ?? []).map(tag => (tag === oldTag ? nextTag : tag)),
+ })
+ )
+ )
+ setItems(current => current.map(item => renamed.find(entry => entry.id === item.id) ?? item))
+ if (registryTags.includes(oldTag)) {
+ await persistRegistry(registryTags.map(tag => (tag === oldTag ? nextTag : tag)))
+ }
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : '重命名标签失败')
+ } finally {
+ setTagBusy(false)
+ }
+ }
+
+ async function deleteTag(target: string) {
+ const count = tagCounts.get(target) ?? 0
+ const hint = count > 0 ? `,并从 ${count} 个任务上移除` : ''
+ if (!window.confirm(`删除标签“${target}”${hint}?`) || tagBusy) return
+ setTagBusy(true)
+ setError(null)
+ try {
+ const affected = items.filter(item => (item.tags ?? []).includes(target))
+ const stripped = await Promise.all(
+ affected.map(item =>
+ api.updateLoopItem(item.id, {
+ version: item.version,
+ tags: (item.tags ?? []).filter(tag => tag !== target),
+ })
+ )
+ )
+ setItems(current => current.map(item => stripped.find(entry => entry.id === item.id) ?? item))
+ if (registryTags.includes(target)) {
+ await persistRegistry(registryTags.filter(tag => tag !== target))
+ }
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : '删除标签失败')
+ } finally {
+ setTagBusy(false)
+ }
+ }
+
+ useEffect(() => {
+ let active = true
+ const normalized = query.trim()
+ if (!normalized) {
+ return () => {
+ active = false
+ }
+ }
+ const timer = window.setTimeout(() => {
+ void api
+ .searchCloudProjectUsers(normalized)
+ .then(response => {
+ if (!active) return
+ const existing = new Set(members.map(member => member.user_id))
+ setResults(response.users.filter(user => !existing.has(user.id)))
+ })
+ .catch(cause => active && setError(cause instanceof Error ? cause.message : '搜索失败'))
+ }, 250)
+ return () => {
+ active = false
+ window.clearTimeout(timer)
+ }
+ }, [api, members, query])
+ const visibleResults = query.trim() ? results : []
+
+ async function addMember(user: CloudUserSearchItem) {
+ if (savingUserId !== null) return
+ setSavingUserId(user.id)
+ setError(null)
+ try {
+ const member = await api.addCloudProjectMember(project.id, user.id, role)
+ setMembers(current => [...current, member])
+ setResults(current => current.filter(result => result.id !== user.id))
+ setQuery('')
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : '添加成员失败')
+ } finally {
+ setSavingUserId(null)
+ }
+ }
+
+ async function updateMember(
+ member: CloudProjectMember,
+ nextRole: Exclude
+ ) {
+ setError(null)
+ try {
+ const updated = await api.updateCloudProjectMember(project.id, member.user_id, nextRole)
+ setMembers(current =>
+ current.map(existing => (existing.user_id === updated.user_id ? updated : existing))
+ )
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : '更新成员失败')
+ }
+ }
+
+ async function removeMember(member: CloudProjectMember) {
+ if (!window.confirm(`从项目中移除“${member.user_name}”?`)) return
+ setError(null)
+ try {
+ await api.removeCloudProjectMember(project.id, member.user_id)
+ setMembers(current => current.filter(existing => existing.user_id !== member.user_id))
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : '移除成员失败')
+ }
+ }
+
+ return (
+
+
+
项目成员
+
+ 成员只能访问被授权的云项目、任务、共享文件和交付。
+
+
+
+ {members.map((member, index) => (
+
+
+ {member.user_name.slice(0, 1)}
+
+
+ {member.user_name}
+ {member.email}
+
+ {member.role === 'Owner' ? (
+ Owner
+ ) : (
+ <>
+
+
+ >
+ )}
+
+ ))}
+
+
+
+
添加成员
+
+
+
+
+ {visibleResults.length > 0 && (
+
+ {visibleResults.map(user => (
+
+ ))}
+
+ )}
+ {error &&
{error}
}
+
+
+
+
标签管理
+
+ 标签用于区分任务类型(如产品需求、研发需求),新建后可在任务上选择,看板支持按标签筛选。
+
+
+
+ setNewTag(event.target.value)}
+ onKeyDown={event => {
+ if (event.key === 'Enter') {
+ event.preventDefault()
+ void createTag()
+ }
+ }}
+ placeholder="输入标签名称,如 产品需求"
+ maxLength={32}
+ className="h-9 min-w-0 flex-1 rounded-lg border border-border bg-background px-3 text-sm outline-none focus:border-text-muted"
+ />
+
+
+
+
+ {allTags.length === 0 && (
+
+ 还没有标签,先新建一个吧
+
+ )}
+ {allTags.map(tag => (
+
+
+ {renamingTag === tag ? (
+
+ setRenameValue(event.target.value)}
+ onKeyDown={event => {
+ if (event.key === 'Enter') {
+ event.preventDefault()
+ void renameTag(tag)
+ }
+ if (event.key === 'Escape') setRenamingTag(null)
+ }}
+ maxLength={32}
+ className="h-7 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-sm outline-none focus:border-text-muted"
+ />
+
+
+
+ ) : (
+ <>
+
{tag}
+
+ {tagCounts.get(tag) ?? 0} 个任务
+
+
+
+ >
+ )}
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/wework/src/features/todo/CloudProjectSettingsDialog.tsx b/wework/src/features/todo/CloudProjectSettingsDialog.tsx
deleted file mode 100644
index d353f58341..0000000000
--- a/wework/src/features/todo/CloudProjectSettingsDialog.tsx
+++ /dev/null
@@ -1,210 +0,0 @@
-import { useEffect, useState } from 'react'
-import { CircleUserRound, Search, Trash2 } from 'lucide-react'
-import type { CloudProject, CloudProjectMember, CloudUserSearchItem } from '@/api/deliveries'
-import type { WorkbenchServices } from '@/features/workbench/workbenchServices'
-import { CloudTodoModal } from './CloudTodoModal'
-
-type DeliveryApi = NonNullable
-
-interface CloudProjectSettingsDialogProps {
- api: DeliveryApi
- project: CloudProject
- onClose: () => void
-}
-
-export function CloudProjectSettingsDialog({
- api,
- project,
- onClose,
-}: CloudProjectSettingsDialogProps) {
- const [members, setMembers] = useState([])
- const [query, setQuery] = useState('')
- const [results, setResults] = useState([])
- const [role, setRole] = useState('Developer')
- const [savingUserId, setSavingUserId] = useState(null)
- const [error, setError] = useState(null)
-
- useEffect(() => {
- let active = true
- void api
- .listCloudProjectMembers(project.id)
- .then(value => active && setMembers(value))
- .catch(cause => active && setError(cause instanceof Error ? cause.message : '加载成员失败'))
- return () => {
- active = false
- }
- }, [api, project.id])
-
- useEffect(() => {
- let active = true
- const normalized = query.trim()
- if (!normalized) {
- return () => {
- active = false
- }
- }
- const timer = window.setTimeout(() => {
- void api
- .searchCloudProjectUsers(normalized)
- .then(response => {
- if (!active) return
- const existing = new Set(members.map(member => member.user_id))
- setResults(response.users.filter(user => !existing.has(user.id)))
- })
- .catch(cause => active && setError(cause instanceof Error ? cause.message : '搜索失败'))
- }, 250)
- return () => {
- active = false
- window.clearTimeout(timer)
- }
- }, [api, members, query])
- const visibleResults = query.trim() ? results : []
-
- async function addMember(user: CloudUserSearchItem) {
- if (savingUserId !== null) return
- setSavingUserId(user.id)
- setError(null)
- try {
- const member = await api.addCloudProjectMember(project.id, user.id, role)
- setMembers(current => [...current, member])
- setResults(current => current.filter(result => result.id !== user.id))
- setQuery('')
- } catch (cause) {
- setError(cause instanceof Error ? cause.message : '添加成员失败')
- } finally {
- setSavingUserId(null)
- }
- }
-
- async function updateMember(
- member: CloudProjectMember,
- nextRole: Exclude
- ) {
- setError(null)
- try {
- const updated = await api.updateCloudProjectMember(project.id, member.user_id, nextRole)
- setMembers(current =>
- current.map(existing => (existing.user_id === updated.user_id ? updated : existing))
- )
- } catch (cause) {
- setError(cause instanceof Error ? cause.message : '更新成员失败')
- }
- }
-
- async function removeMember(member: CloudProjectMember) {
- if (!window.confirm(`从项目中移除“${member.user_name}”?`)) return
- setError(null)
- try {
- await api.removeCloudProjectMember(project.id, member.user_id)
- setMembers(current => current.filter(existing => existing.user_id !== member.user_id))
- } catch (cause) {
- setError(cause instanceof Error ? cause.message : '移除成员失败')
- }
- }
-
- return (
-
-
-
{project.name}
-
- 成员只能访问被授权的云项目、任务、共享文件和交付。
-
-
-
- {members.map(member => (
-
-
-
- {member.user_name}
- {member.email}
-
- {member.role === 'Owner' ? (
- Owner
- ) : (
- <>
-
-
- >
- )}
-
- ))}
-
-
-
-
添加成员
-
-
-
-
- {visibleResults.length > 0 && (
-
- {visibleResults.map(user => (
-
- ))}
-
- )}
- {error &&
{error}
}
-
-
-
- )
-}
diff --git a/wework/src/features/todo/CloudTodoModal.tsx b/wework/src/features/todo/CloudTodoModal.tsx
index e5faf31f3b..4c4e6d8086 100644
--- a/wework/src/features/todo/CloudTodoModal.tsx
+++ b/wework/src/features/todo/CloudTodoModal.tsx
@@ -10,17 +10,17 @@ interface CloudTodoModalProps {
export function CloudTodoModal({ title, children, onClose }: CloudTodoModalProps) {
return (
event.currentTarget === event.target && onClose()}
>
-
-
- {title}
+
+
+ {title}