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 @@ + + + + + +看板 · 项目空间 + + + + + + + + +
+
+
+ + 看板项目 +
+ +
+ 事项 + 文件 +
+ +
+ + + +
+ +
+

顶层任务

+ 3 个任务 · 更新于今天 +
+ +
+ + +
+
+ + 收集箱 + 2 + + + +
+ +
+
1FFD6B9-2
+
整理项目需求文档
+
+ 普通 + 07-24 +
+
+ + + 1 个子任务 + + + + + 子任务 + +
+
+ +
+
1FFD6B9-1
+
搭建 CI 流水线
+
+ 重要 + 07-24 +
+
+ 暂无子任务 + + + 子任务 + +
+
+ +
+ + 新建工作项 +
+
+ + +
+
+ + 待开始 + 1 + + + +
+ +
+
1FFD6B9-3
+
设计评审与走查
+
+ 紧急 + 07-24 +
+
+ + + 2 个子任务 + + + + + 子任务 + +
+
+ +
+ + 新建工作项 +
+
+ + +
+
+ + 进行中 + 0 + + + +
+
拖拽任务到这里开始处理
+
+ + 新建工作项 +
+
+ + +
+
+ + 待确认 + 0 + + + +
+
等待确认的任务会显示在这里
+
+ + 新建工作项 +
+
+ + +
+
+ + 已完成 + 0 + + + +
+
已完成的任务会归档在这里
+
+ + 新建工作项 +
+
+ +
+
+ + +
+
+
+ + 1FFD6B9-3 + 已保存 + +
+ +
+

设计评审与走查

+ +
+
+
状态
+
待开始
+
+
+
优先级
+
紧急
+
+
+
负责人
+
添加负责人
+
+
+
截止时间
+
07-28
+
+
+
父任务
+
无父任务
+
+
+ +
+
描述
+
+

目标:对看板重设计稿进行完整走查,确认信息层级与交互状态。

+

覆盖事项看板、文件视图、任务详情三个核心页面;重点检查空状态、hover 反馈与徽章用色是否克制。

+

走查结论记录在本任务的评论中,问题项拆分为子任务跟进。

+
+
+ +
+
+ 子任务 + 2 + + + 新建子任务 + +
+
+ + 1FFD6B9-4 + 核对看板列的拖拽排序 + 进行中 +
+
+ + 1FFD6B9-5 + 确认任务详情抽屉的字段完整性 + 待开始 +
+
+ +
+
+ 参与者 + + + 添加参与者 + +
+
+
L
+ local + 自动加入 +
+
+ +
+
+ 附件 + + + 添加附件 + +
+
+ + design-spec.pdf + + 2.4 MB + + + +
+
+ +
+
本地执行
+
尚未关联本地任务
+
+
+ + +
+
+ + +
+
+
+

新建任务

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+
+

开启本地任务

+ +
+
+
+ 1FFD6B9-3 + 设计评审与走查 +
+
+ + +
+
+ + +
+
新任务会获得当前项目空间上下文,可读取共享目录、任务和历史交付,但不会自动上传本地会话。
+
+ +
+
+ + +
+
+
+
+

项目成员

+
成员只能访问被授权的云项目、任务、共享文件和交付。
+
+ +
+
+
+
L
+
+
local
+ +
+ Owner +
+
+
+
+
陈晨
+ +
+ + +
+
+
+
+
林一
+ +
+ + +
+ +
+ + + +
+
+
+
+ + + + 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 @@ + + + + + +我的工作 + + + + + + + + + + +
+
+
+ + + +
+
+ + +
+
+ + 需要我处理 + 3 +
+
+ 1FFD6B9-3 + 设计评审与走查 + 看板项目 +
+
+ A2C41F0-1 + 梳理 V4 版本的里程碑拆分 + Wegent V4 +
+
+ A2C41F0-7 + 确认执行器镜像的发布清单 + Wegent V4 +
+
+ + +
+
+ + 正在执行 + 2 +
+
+ 1FFD6B9-4 + 核对看板列的拖拽排序 + 看板项目 +
+
+ A2C41F0-2 + 多页面静态 demo 搭建 + Wegent V4 +
+
+ + +
+
+ + 等待确认 + 2 +
+
+ 1FFD6B9-2 + 整理项目需求文档 + 看板项目 +
+
+ A2C41F0-5 + 共享文件权限模型评审 + Wegent V4 +
+
+ + +
+
+ + 已完成 + 1 +
+
+ 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 @@ + + + + + +项目空间 + + + + + + + + +
+
+
+ + + + +
项目空间只包含共享协作数据;本地目录、Git 与未关联会话仍留在成员设备上。
+
+
+
+ + +
+
+
+

新建项目空间

+ +
+
+
项目空间包含共享任务、文件与交付;成员可以关联各自不同的本地工作区。
+
+ + +
项目标识将在创建时自动生成。
+
+
+ + +
+
+ +
+
+ + + + 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 => ( -
+ 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" + /> + - {entry.kind === 'file' && ( - - )} - - -
- )) + 创建 + + +
)} -
-
-
-

交付快照

- 来自已完成任务,只读且不可修改 -
-
-
- 任务 + {error && ( +

+ {error} +

+ )} +
+
名称 类型 - 交付时间 + 更新时间 大小
- {deliveryFiles.length === 0 ? ( -
- 暂无交付文件 + {files.length === 0 ? ( +
+ 暂无共享文件
) : ( - deliveryFiles.map(entry => ( + files.map(entry => (
- - {entry.loop_item_id} - {entry.loop_item_title} + + {entry.kind === 'folder' ? ( + + ) : ( + + )} + {editingFileId === entry.id ? ( + setEditingPath(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') void moveFile(entry) + if (event.key === 'Escape') setEditingFileId(null) + }} + className="h-7 min-w-0 flex-1 rounded border border-focus bg-background px-2 outline-none" + /> + ) : ( + + {entry.path} + + )} - - - - {entry.relative_path} - + {entry.content_type || '文件夹'} + {entry.updated_at.slice(0, 10)} + + {entry.kind === 'file' ? `${entry.size_bytes} B` : '—'} + + + + {entry.kind === 'file' && ( + + )} + - {entry.content_type || '文件'} - {entry.delivered_at.slice(0, 10)} - {entry.size_bytes} B -
)) )}
-
-

- 在 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}

-
- {childCount > 0 && ( +
+ {childCount > 0 ? ( + ) : ( + 暂无子任务 )}