diff --git a/server/app/domains/remote_control/service/remote_control_service.py b/server/app/domains/remote_control/service/remote_control_service.py index 8fb54d9e..bf6590dc 100644 --- a/server/app/domains/remote_control/service/remote_control_service.py +++ b/server/app/domains/remote_control/service/remote_control_service.py @@ -324,25 +324,40 @@ class RemoteControlService: return RemoteControlService._get_owned_space(user_id, history.space_id, db) return None + @staticmethod + def _ensure_remote_control_supported_space(space: Space) -> None: + if space.source_type == SpaceSourceType.LEGACY: + raise HTTPException( + status_code=400, + detail={ + "code": "REMOTE_CONTROL_LEGACY_SPACE_UNSUPPORTED", + "message": "Legacy Spaces do not support remote control.", + }, + ) + @staticmethod def _session_space(session: RemoteControlSession, user_id: int, db: Session) -> Space: if session.space_id: - return RemoteControlService._get_owned_space(user_id, session.space_id, db) + space = RemoteControlService._get_owned_space(user_id, session.space_id, db) + RemoteControlService._ensure_remote_control_supported_space(space) + return space project_id, _, _, _ = RemoteControlService._effective_target(session) if project_id: space = RemoteControlService._space_for_project(user_id, project_id, db) if space: + RemoteControlService._ensure_remote_control_supported_space(space) session.space_id = space.id session.space_name_snapshot = space.name db.add(session) db.flush() return space - space = SpaceService.ensure_legacy_space(user_id, db) - session.space_id = space.id - session.space_name_snapshot = space.name - db.add(session) - db.flush() - return space + raise HTTPException( + status_code=400, + detail={ + "code": "REMOTE_CONTROL_SPACE_REQUIRED", + "message": "Remote control requires a non-legacy Space.", + }, + ) @staticmethod def _ensure_folder_space(space: Space) -> None: @@ -515,9 +530,22 @@ class RemoteControlService: elif target_project_id: space = RemoteControlService._space_for_project(user_id, target_project_id, db) if not space: - space = SpaceService.ensure_legacy_space(user_id, db) + raise HTTPException( + status_code=400, + detail={ + "code": "REMOTE_CONTROL_SPACE_REQUIRED", + "message": "Remote control requires a non-legacy Space.", + }, + ) else: - space = SpaceService.ensure_legacy_space(user_id, db) + raise HTTPException( + status_code=400, + detail={ + "code": "REMOTE_CONTROL_SPACE_REQUIRED", + "message": "Remote control requires a non-legacy Space.", + }, + ) + RemoteControlService._ensure_remote_control_supported_space(space) if target_project_id: project = RemoteControlService._get_owned_project(user_id, target_project_id, db) if project and project.space_id != space.id: diff --git a/server/app/domains/space/service/space_service.py b/server/app/domains/space/service/space_service.py index e6aac4a1..79ba47cd 100644 --- a/server/app/domains/space/service/space_service.py +++ b/server/app/domains/space/service/space_service.py @@ -29,6 +29,7 @@ from app.model.project import ( Project, ProjectIn, ProjectOut, + ProjectStatus, ProjectUpdate, ProjectWorkdirMode, ) @@ -506,12 +507,31 @@ class SpaceService: @staticmethod def list_spaces(user_id: int | str, s: Session) -> list[SpaceOut]: canonical_user_id = SpaceService.canonical_user_id(user_id) - SpaceService.ensure_legacy_space(canonical_user_id, s) spaces = s.exec( select(Space) .where(Space.user_id == canonical_user_id) .order_by(Space.updated_at.desc()) ).all() + legacy_space_ids = [ + space.id + for space in spaces + if space.source_type == SpaceSourceType.LEGACY + ] + if legacy_space_ids: + legacy_space_ids_with_projects = set( + s.exec( + select(Project.space_id) + .where(Project.user_id == canonical_user_id) + .where(Project.space_id.in_(legacy_space_ids)) + .where(Project.status != ProjectStatus.ARCHIVED) + ).all() + ) + spaces = [ + space + for space in spaces + if space.source_type != SpaceSourceType.LEGACY + or space.id in legacy_space_ids_with_projects + ] return [SpaceOut.from_model(space) for space in spaces] @staticmethod diff --git a/server/tests/test_space_legacy_behavior.py b/server/tests/test_space_legacy_behavior.py new file mode 100644 index 00000000..f0b2522f --- /dev/null +++ b/server/tests/test_space_legacy_behavior.py @@ -0,0 +1,112 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException + +from app.domains.remote_control.service.remote_control_service import RemoteControlService +from app.domains.space.service.space_service import SpaceService +from app.model.space.space import SpaceSourceType + + +class _ExecResult: + def __init__(self, rows): + self._rows = rows + + def all(self): + return self._rows + + +class _ListSpacesSession: + def __init__(self, rows=None, project_space_ids=None): + self._rows = rows or [] + self._project_space_ids = project_space_ids or [] + self._exec_count = 0 + + def exec(self, _statement): + self._exec_count += 1 + if self._exec_count > 1: + return _ExecResult(self._project_space_ids) + return _ExecResult(self._rows) + + +def _space( + space_id: str, + source_type: str, + *, + user_id: str = "user_new", + name: str = "Untitled Space", +): + return SimpleNamespace( + id=space_id, + user_id=user_id, + name=name, + description=None, + source_type=source_type, + root_path=None, + root_fingerprint=None, + status="active", + schema_version=1, + metadata_json={"legacy": True} if source_type == SpaceSourceType.LEGACY else None, + created_at=None, + updated_at=None, + ) + + +def test_list_spaces_does_not_create_legacy_for_empty_user(monkeypatch): + def fail_ensure(*_args, **_kwargs): + raise AssertionError("list_spaces must not create Legacy Space") + + monkeypatch.setattr(SpaceService, "ensure_legacy_space", fail_ensure) + + assert SpaceService.list_spaces("user_new", _ListSpacesSession()) == [] + + +def test_list_spaces_hides_empty_legacy_space(): + spaces = [ + _space("legacy_user_new", SpaceSourceType.LEGACY, name="Legacy Space"), + _space("space_new", SpaceSourceType.BLANK), + ] + + result = SpaceService.list_spaces("user_new", _ListSpacesSession(spaces)) + + assert [space.id for space in result] == ["space_new"] + + +def test_list_spaces_keeps_legacy_space_with_active_projects(): + legacy_space = _space( + "legacy_user_old", + SpaceSourceType.LEGACY, + user_id="user_old", + name="Legacy Space", + ) + + result = SpaceService.list_spaces( + "user_old", + _ListSpacesSession([legacy_space], project_space_ids=["legacy_user_old"]), + ) + + assert [space.id for space in result] == ["legacy_user_old"] + + +def test_remote_control_rejects_legacy_space(): + with pytest.raises(HTTPException) as exc: + RemoteControlService._ensure_remote_control_supported_space( + SimpleNamespace(source_type=SpaceSourceType.LEGACY) + ) + + assert exc.value.status_code == 400 + assert exc.value.detail["code"] == "REMOTE_CONTROL_LEGACY_SPACE_UNSUPPORTED" diff --git a/src/components/Dispatch/index.tsx b/src/components/Dispatch/index.tsx index 731d0b92..a1880d69 100644 --- a/src/components/Dispatch/index.tsx +++ b/src/components/Dispatch/index.tsx @@ -26,6 +26,7 @@ import { revokeRemoteControlSession, waitForRemoteControlBridgeConnected, } from '@/lib/remoteControl'; +import { canUseRemoteControlInSpace } from '@/lib/spaceLabel'; import { cn } from '@/lib/utils'; import { getConnectionConfig } from '@/store/connectionStore'; import { useProjectRuntimeStore } from '@/store/projectRuntimeStore'; @@ -176,6 +177,7 @@ interface ChannelRowProps { icon?: React.ReactNode; imgSrc?: string; disabled?: boolean; + disabledLabel?: string; comingSoon?: boolean; hasActiveSessions?: boolean; loading?: boolean; @@ -187,12 +189,14 @@ function ChannelRow({ icon, imgSrc, disabled, + disabledLabel, comingSoon, hasActiveSessions, loading, onStart, }: ChannelRowProps) { const { t } = useTranslation(); + const isDisabled = Boolean(disabled); return (
@@ -228,13 +232,19 @@ function ChannelRow({ )} - {!disabled && !comingSoon && hasActiveSessions && ( + {isDisabled && !comingSoon && disabledLabel && ( + + {disabledLabel} + + )} + + {!isDisabled && !comingSoon && hasActiveSessions && ( {t('layout.dispatch-connected', { defaultValue: 'Connected' })} )} - {!disabled && !comingSoon && !hasActiveSessions && ( + {!isDisabled && !comingSoon && !hasActiveSessions && (