fix: scope spaces by user and disable legacy remote control

This commit is contained in:
4pmtong 2026-06-26 21:31:30 +08:00
parent 1fa65f3cf3
commit c0f0ccd3d4
8 changed files with 616 additions and 56 deletions

View file

@ -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:

View file

@ -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

View file

@ -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"

View file

@ -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 (
<div
@ -200,9 +204,9 @@ function ChannelRow({
'group/row flex h-[44px] w-full cursor-default select-none items-center gap-3 rounded-xl',
'border border-transparent bg-ds-bg-neutral-default-default px-3',
'transition-colors duration-150',
!disabled && 'hover:border-ds-border-neutral-subtle-default',
!isDisabled && 'hover:border-ds-border-neutral-subtle-default',
hasActiveSessions && 'border-ds-border-neutral-subtle-default',
disabled && 'cursor-not-allowed opacity-50'
isDisabled && 'cursor-not-allowed opacity-50'
)}
>
<div className="flex shrink-0 items-center">
@ -228,13 +232,19 @@ function ChannelRow({
</span>
)}
{!disabled && !comingSoon && hasActiveSessions && (
{isDisabled && !comingSoon && disabledLabel && (
<span className="shrink-0 rounded-full bg-ds-bg-neutral-muted-default px-2 py-0.5 text-label-xs text-ds-text-neutral-muted-default">
{disabledLabel}
</span>
)}
{!isDisabled && !comingSoon && hasActiveSessions && (
<span className="shrink-0 rounded-full bg-ds-bg-success-subtle-default px-2 py-0.5 text-label-xs text-ds-text-success-strong-default">
{t('layout.dispatch-connected', { defaultValue: 'Connected' })}
</span>
)}
{!disabled && !comingSoon && !hasActiveSessions && (
{!isDisabled && !comingSoon && !hasActiveSessions && (
<Button
type="button"
variant="secondary"
@ -332,6 +342,8 @@ export function WorkspaceDispatch() {
s.activeSpaceId ? s.spaces[s.activeSpaceId] : null
);
const activeProjectId = useProjectRuntimeStore((s) => s.activeProjectId);
const remoteControlUnsupportedForSpace =
activeSpace !== null && !canUseRemoteControlInSpace(activeSpace);
const activeSessions = useTriggerStore((s) => s.activeRemoteControlSessions);
const logs = useTriggerStore((s) => s.remoteControlLogs);
@ -416,6 +428,10 @@ export function WorkspaceDispatch() {
toast.error('Open a Space before starting remote control.');
return;
}
if (!activeSpace || !canUseRemoteControlInSpace(activeSpace)) {
toast.error('Legacy Spaces do not support remote control.');
return;
}
setRemoteControlLoading(true);
try {
@ -478,6 +494,7 @@ export function WorkspaceDispatch() {
}
}, [
activeProjectId,
activeSpace,
activeSpace?.name,
activeSpaceId,
addRemoteControlSession,
@ -599,6 +616,10 @@ export function WorkspaceDispatch() {
/>
}
hasActiveSessions={hasActiveRemoteControl}
disabled={remoteControlUnsupportedForSpace && !hasActiveRemoteControl}
disabledLabel={t('layout.dispatch-unavailable', {
defaultValue: 'Unavailable',
})}
loading={remoteControlLoading}
onStart={() => void handleCreateRemoteControl()}
/>

View file

@ -65,6 +65,14 @@ export function canCreateProjectInSpace(
return !isLegacySpace(space);
}
/** Remote control requires a regular Space target; legacy migration buckets are unsupported. */
export function canUseRemoteControlInSpace(
space: Space | null | undefined
): boolean {
if (!space) return false;
return !isLegacySpace(space);
}
/** Folder-backed and scratch Spaces with a local workspace root behave locally. */
export function isLocalWorkspaceSpace(
space: Space | null | undefined

View file

@ -131,11 +131,13 @@ const getRandomDefaultModel = (): CloudModelType => {
};
const hydrateSpacesForUser = (userId: number | string | null | undefined) => {
if (!useSpaceStore?.getState) return;
if (userId === null || userId === undefined || userId === '') {
useSpaceStore.getState().resetForUser(null);
useSpaceStore.getState().ensureLegacySpace();
return;
}
useSpaceStore.getState().ensureLegacySpace(userId);
useSpaceStore.getState().resetForUser(userId);
void useSpaceStore.getState().hydrateFromServer(userId);
};
@ -173,6 +175,11 @@ const authStore = create<AuthState>()(
// auth related methods
setAuth: ({ token, username, email, user_id }) => {
const previousUserId = get().user_id;
if (previousUserId != null && previousUserId !== user_id) {
void clearAllCachedProjects(previousUserId);
}
useSpaceStore.getState().resetForUser(user_id);
set({ token, username, email, user_id });
hydrateSpacesForUser(user_id);
},
@ -193,6 +200,7 @@ const authStore = create<AuthState>()(
initState: 'carousel',
localProxyValue: null,
});
useSpaceStore.getState().resetForUser(null);
},
// set related methods
@ -433,12 +441,9 @@ export const useAuthStore = authStore;
export const getAuthStore = () => authStore.getState();
queueMicrotask(() => {
const { token, user_id } = authStore.getState();
if (token) {
hydrateSpacesForUser(user_id);
} else {
useSpaceStore.getState().ensureLegacySpace(user_id);
}
if (!useSpaceStore?.getState) return;
const { user_id } = authStore.getState();
hydrateSpacesForUser(user_id);
});
// constant definition

View file

@ -0,0 +1,196 @@
// ========= 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. =========
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
SPACE_SCHEMA_VERSION,
type Space,
type SpaceSourceType,
useSpaceStore,
} from './spaceStore';
const authStoreMock = vi.hoisted(() => ({
state: {
user_id: 2,
email: 'new@example.com',
},
}));
vi.mock('@/store/authStore', () => ({
getAuthStore: () => authStoreMock.state,
}));
vi.mock('@/api/http', () => ({
proxyFetchGet: vi.fn().mockResolvedValue({ projects: [] }),
}));
vi.mock('@/service/spaceApi', () => ({
proxyCreateSpace: vi.fn(),
proxyEnsureLegacySpace: vi.fn(),
proxyFetchSpaceProjects: vi.fn(),
proxyFetchSpaces: vi.fn(),
}));
vi.mock('@/service/workspaceApi', () => ({
reconcileWorkspaceBindings: vi.fn().mockResolvedValue(undefined),
}));
const makeSpace = (
id: string,
name: string,
sourceType: SpaceSourceType,
userId = '2',
metadata?: Space['metadata']
): Space => ({
id,
name,
userId,
sourceType,
rootPath: null,
rootFingerprint: null,
status: 'active',
schemaVersion: SPACE_SCHEMA_VERSION,
createdAt: 1,
updatedAt: 1,
metadata,
});
describe('spaceStore user scoping', () => {
beforeEach(async () => {
vi.clearAllMocks();
const spaceApi = await import('@/service/spaceApi');
vi.mocked(spaceApi.proxyFetchSpaces).mockResolvedValue([]);
vi.mocked(spaceApi.proxyFetchSpaceProjects).mockResolvedValue([]);
vi.mocked(spaceApi.proxyCreateSpace).mockResolvedValue(
makeSpace('space_created', 'Untitled Space', 'blank')
);
vi.mocked(spaceApi.proxyEnsureLegacySpace).mockResolvedValue(
makeSpace('legacy_2', 'Legacy Space', 'legacy', '2', { legacy: true })
);
authStoreMock.state = {
email: 'new@example.com',
user_id: 2,
};
useSpaceStore.setState({
activeSpaceId: 'space_old_blank',
spaces: {
space_old_blank: {
id: 'space_old_blank',
name: 'Untitled Space',
userId: '1',
sourceType: 'blank',
status: 'active',
schemaVersion: SPACE_SCHEMA_VERSION,
createdAt: 1,
updatedAt: 3,
},
legacy_1: {
id: 'legacy_1',
name: 'Legacy Space',
userId: '1',
sourceType: 'legacy',
status: 'active',
schemaVersion: SPACE_SCHEMA_VERSION,
createdAt: 1,
updatedAt: 2,
metadata: { legacy: true },
},
space_new_blank: {
id: 'space_new_blank',
name: 'Untitled Space',
userId: '2',
sourceType: 'blank',
status: 'active',
schemaVersion: SPACE_SCHEMA_VERSION,
createdAt: 1,
updatedAt: 4,
},
},
lastVisitedProjectBySpace: {
space_old_blank: 'project_old',
space_new_blank: 'project_new',
},
projectsBySpaceId: {
space_old_blank: {
project_old: {
id: 'project_old',
userId: '1',
spaceId: 'space_old_blank',
name: 'Old project',
status: 'active',
createdAt: 1,
updatedAt: 1,
},
},
space_new_blank: {
project_new: {
id: 'project_new',
userId: '2',
spaceId: 'space_new_blank',
name: 'New project',
status: 'active',
createdAt: 1,
updatedAt: 1,
},
},
},
projectIdIndex: {
project_old: 'space_old_blank',
project_new: 'space_new_blank',
},
projectsSyncedAt: {
space_old_blank: 100,
legacy_1: 100,
space_new_blank: 200,
},
});
});
it('removes spaces and project metadata from the previous signed-in user', () => {
useSpaceStore.getState().resetForUser(2);
const state = useSpaceStore.getState();
expect(Object.keys(state.spaces)).toEqual(['space_new_blank']);
expect(state.activeSpaceId).toBe('space_new_blank');
expect(Object.keys(state.projectsBySpaceId)).toEqual(['space_new_blank']);
expect(state.projectIdIndex).toEqual({ project_new: 'space_new_blank' });
expect(state.lastVisitedProjectBySpace).toEqual({
space_new_blank: 'project_new',
});
expect(state.projectsSyncedAt).toEqual({ space_new_blank: 200 });
});
it('hydrates new accounts with one blank space and hides empty legacy rows', async () => {
const spaceApi = await import('@/service/spaceApi');
vi.mocked(spaceApi.proxyFetchSpaces).mockResolvedValue([
makeSpace('legacy_2', 'Legacy Space', 'legacy', '2', { legacy: true }),
]);
vi.mocked(spaceApi.proxyCreateSpace).mockResolvedValue(
makeSpace('space_new_blank', 'Untitled Space', 'blank')
);
await useSpaceStore.getState().hydrateFromServer(2);
const state = useSpaceStore.getState();
expect(spaceApi.proxyFetchSpaceProjects).toHaveBeenCalledWith('legacy_2');
expect(spaceApi.proxyCreateSpace).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Untitled Space',
source_type: 'blank',
})
);
expect(Object.keys(state.spaces)).toEqual(['space_new_blank']);
expect(state.activeSpaceId).toBe('space_new_blank');
});
});

View file

@ -19,6 +19,7 @@ import {
type SessionNavLeadPresentation,
} from '@/lib/sessionNavLead';
import {
isLegacySpace,
isLocalWorkspaceSpace,
isPlaceholderProjectName,
isPlaceholderSpaceNameStatic,
@ -39,6 +40,8 @@ export const DEFAULT_LOCAL_USER_ID = 'local';
const PROJECT_SYNC_TTL_MS = 5 * 60 * 1000;
const PROJECT_PLACEHOLDER_RESYNC_MS = 10 * 1000;
const PROJECT_DISPLAY_NAME_MAX = 80;
const INITIAL_BLANK_SPACE_NAME = 'Untitled Space';
const INITIAL_BLANK_SPACE_CREATED_FROM = 'initial_hydrate';
export type SpaceSourceType = 'blank' | 'folder' | 'legacy';
export type SpaceStatus = 'active' | 'disconnected' | 'archived';
@ -102,6 +105,7 @@ interface SpaceStore {
projectsBySpaceId: Record<string, Record<string, SpaceProjectMeta>>;
projectIdIndex: Record<string, string>;
projectsSyncedAt: Record<string, number>;
resetForUser: (userId?: string | number | null) => void;
ensureLegacySpace: (userId?: string | number | null) => string;
hydrateFromServer: (userId?: string | number | null) => Promise<void>;
syncProjectsFromServer: (spaceId: string) => Promise<void>;
@ -160,6 +164,12 @@ const canonicalUserId = (userId?: string | number | null) =>
? DEFAULT_LOCAL_USER_ID
: String(userId);
const spaceBelongsToUser = (space: Space, userId?: string | number | null) => {
const ownerId = canonicalUserId(userId);
if (!space.userId) return ownerId === DEFAULT_LOCAL_USER_ID;
return String(space.userId) === ownerId;
};
export const legacySpaceIdForUser = (userId?: string | number | null) =>
`legacy_${canonicalUserId(userId)}`;
@ -233,6 +243,7 @@ const hasVisibleProjectsForSpace = (
) => getVisibleProjectMetasForSpace(projectsBySpaceId, spaceId).length > 0;
const DISPOSABLE_BLANK_SPACE_CREATED_FROM = new Set([
INITIAL_BLANK_SPACE_CREATED_FROM,
'home_hub_toolbar',
'top_bar',
'project_sidebar_space_selector',
@ -328,6 +339,40 @@ const pruneAutoCreatedProjectMetas = (
};
};
const activeSpacesByRecentUpdate = (spaces: Record<string, Space>) =>
Object.values(spaces)
.filter((space) => space.status === 'active')
.sort((a, b) => b.updatedAt - a.updatedAt);
const pickHydratedActiveSpaceId = (
spaces: Record<string, Space>,
currentActiveSpaceId: string | null | undefined,
localLegacyId: string,
preferredSpaceId?: string | null
) => {
if (preferredSpaceId && spaces[preferredSpaceId]?.status === 'active') {
return preferredSpaceId;
}
const currentActiveSpace = currentActiveSpaceId
? spaces[currentActiveSpaceId]
: null;
if (
currentActiveSpace &&
currentActiveSpace.status === 'active' &&
currentActiveSpace.id !== localLegacyId
) {
return currentActiveSpace.id;
}
const activeSpaces = activeSpacesByRecentUpdate(spaces);
return (
activeSpaces.find((space) => !isLegacySpace(space))?.id ??
activeSpaces[0]?.id ??
null
);
};
const truncateProjectDisplayName = (name: string) =>
name.length > PROJECT_DISPLAY_NAME_MAX
? `${name.slice(0, PROJECT_DISPLAY_NAME_MAX - 3)}...`
@ -429,6 +474,15 @@ const unbindBrainWorkspaceMirror = async (spaceId: string) => {
await unbindWorkspaceFromBrain(spaceId, email, userId);
};
const isHydrationStillCurrentForUser = async (ownerId: string) => {
try {
const { getAuthStore } = await import('@/store/authStore');
return canonicalUserId(getAuthStore().user_id) === ownerId;
} catch {
return true;
}
};
export const useSpaceStore = create<SpaceStore>()(
persist(
(set, get) => ({
@ -439,22 +493,138 @@ export const useSpaceStore = create<SpaceStore>()(
projectIdIndex: {},
projectsSyncedAt: {},
resetForUser: (userId) =>
set((state) => {
const nextSpaces: Record<string, Space> = {};
for (const [spaceId, space] of Object.entries(state.spaces)) {
if (spaceBelongsToUser(space, userId)) {
nextSpaces[spaceId] = space;
}
}
const nextProjectsBySpaceId: Record<
string,
Record<string, SpaceProjectMeta>
> = {};
const nextProjectIdIndex: Record<string, string> = {};
for (const spaceId of Object.keys(nextSpaces)) {
const projects = state.projectsBySpaceId[spaceId];
if (!projects) continue;
nextProjectsBySpaceId[spaceId] = projects;
for (const projectId of Object.keys(projects)) {
nextProjectIdIndex[projectId] = spaceId;
}
}
const nextLastVisitedProjectBySpace: Record<string, string> = {};
for (const [spaceId, projectId] of Object.entries(
state.lastVisitedProjectBySpace
)) {
if (nextProjectsBySpaceId[spaceId]?.[projectId]) {
nextLastVisitedProjectBySpace[spaceId] = projectId;
}
}
const nextProjectsSyncedAt: Record<string, number> = {};
for (const spaceId of Object.keys(nextSpaces)) {
const syncedAt = state.projectsSyncedAt[spaceId];
if (syncedAt !== undefined) {
nextProjectsSyncedAt[spaceId] = syncedAt;
}
}
const localLegacyId = legacySpaceIdForUser(DEFAULT_LOCAL_USER_ID);
return {
spaces: nextSpaces,
activeSpaceId: pickHydratedActiveSpaceId(
nextSpaces,
state.activeSpaceId,
localLegacyId
),
lastVisitedProjectBySpace: nextLastVisitedProjectBySpace,
projectsBySpaceId: nextProjectsBySpaceId,
projectIdIndex: nextProjectIdIndex,
projectsSyncedAt: nextProjectsSyncedAt,
};
}),
hydrateFromServer: async (userId) => {
try {
const [{ proxyEnsureLegacySpace, proxyFetchSpaces }, projectModule] =
await Promise.all([
import('@/service/spaceApi'),
import('./projectRuntimeStore'),
]);
const [legacySpace, serverSpaces] = await Promise.all([
proxyEnsureLegacySpace(),
proxyFetchSpaces(),
const [
{ proxyCreateSpace, proxyFetchSpaceProjects, proxyFetchSpaces },
projectModule,
] = await Promise.all([
import('@/service/spaceApi'),
import('./projectRuntimeStore'),
]);
const spaces = serverSpaces.some(
(space) => space.id === legacySpace.id
)
? serverSpaces
: [legacySpace, ...serverSpaces];
const ownerId = canonicalUserId(userId);
const serverSpaces = await proxyFetchSpaces();
if (!(await isHydrationStillCurrentForUser(ownerId))) {
return;
}
const ownedSpaces = serverSpaces.filter(
(space) => !space.userId || String(space.userId) === ownerId
);
const activeOwnedSpaces = ownedSpaces.filter(
(space) => space.status === 'active'
);
const hasActiveNonLegacySpace = activeOwnedSpaces.some(
(space) => !isLegacySpace(space)
);
const activeLegacySpaces = activeOwnedSpaces.filter(isLegacySpace);
const legacySpaceIdsWithProjects = new Set<string>();
let initialBlankSpace: Space | null = null;
for (const legacySpace of activeLegacySpaces) {
try {
const legacyProjects = await proxyFetchSpaceProjects(
legacySpace.id
);
if (
legacyProjects.some((project) => project.status !== 'archived')
) {
legacySpaceIdsWithProjects.add(legacySpace.id);
}
} catch (error) {
console.warn(
`[spaceStore] Failed to inspect legacy Space ${legacySpace.id}; preserving it as the active fallback:`,
error
);
legacySpaceIdsWithProjects.add(legacySpace.id);
}
}
if (!hasActiveNonLegacySpace) {
if (legacySpaceIdsWithProjects.size === 0) {
try {
initialBlankSpace = await proxyCreateSpace({
name: INITIAL_BLANK_SPACE_NAME,
source_type: 'blank',
metadata: {
createdFrom: INITIAL_BLANK_SPACE_CREATED_FROM,
autoCreatedPlaceholder: true,
},
});
} catch (error) {
console.warn(
'[spaceStore] Failed to create initial blank Space:',
error
);
}
}
}
if (!(await isHydrationStillCurrentForUser(ownerId))) {
return;
}
const visibleOwnedSpaces = ownedSpaces.filter(
(space) =>
!isLegacySpace(space) || legacySpaceIdsWithProjects.has(space.id)
);
const spaces = initialBlankSpace
? [initialBlankSpace, ...visibleOwnedSpaces]
: visibleOwnedSpaces;
void Promise.all([
import('@/service/workspaceApi'),
import('@/store/authStore'),
@ -464,7 +634,10 @@ export const useSpaceStore = create<SpaceStore>()(
const userId = authModule.getAuthStore().user_id;
if (!email) return;
const bindingSpaceIds = spaces
.filter((space) => space.status !== 'archived')
.filter(
(space) =>
space.status !== 'archived' && !isLegacySpace(space)
)
.map((space) => space.id);
return workspaceModule
.reconcileWorkspaceBindings(email, bindingSpaceIds, userId)
@ -500,28 +673,24 @@ export const useSpaceStore = create<SpaceStore>()(
);
});
const localLegacyId = legacySpaceIdForUser(DEFAULT_LOCAL_USER_ID);
const serverLegacyId = legacySpace.id;
const ownerId = canonicalUserId(userId ?? legacySpace.userId);
const serverLegacySpace = spaces.find(isLegacySpace);
const serverLegacyId =
serverLegacySpace?.id ?? legacySpaceIdForUser(ownerId);
const hasServerLegacySpace = Boolean(serverLegacySpace);
set((state) => {
const nextSpaces: Record<string, Space> = {};
for (const space of spaces) {
if (!space.userId || String(space.userId) === ownerId) {
nextSpaces[space.id] = space;
}
nextSpaces[space.id] = space;
}
if (!nextSpaces[serverLegacyId]) {
nextSpaces[serverLegacyId] = legacySpace;
}
const shouldAdoptServerLegacy =
!state.activeSpaceId ||
state.activeSpaceId === localLegacyId ||
!nextSpaces[state.activeSpaceId];
return {
spaces: nextSpaces,
activeSpaceId: shouldAdoptServerLegacy
? serverLegacyId
: state.activeSpaceId,
activeSpaceId: pickHydratedActiveSpaceId(
nextSpaces,
state.activeSpaceId,
localLegacyId,
initialBlankSpace?.id
),
};
});
@ -543,11 +712,13 @@ export const useSpaceStore = create<SpaceStore>()(
projectIdIndex: pruned.projectIdIndex,
};
});
rehomeLegacyRuntimeProjects(
projectStore,
localLegacyId,
serverLegacyId
);
if (hasServerLegacySpace) {
rehomeLegacyRuntimeProjects(
projectStore,
localLegacyId,
serverLegacyId
);
}
const activeSpaceId = get().activeSpaceId;
if (activeSpaceId && get().shouldSyncProjects(activeSpaceId)) {
@ -561,7 +732,6 @@ export const useSpaceStore = create<SpaceStore>()(
'[spaceStore] Failed to hydrate spaces from server:',
error
);
get().ensureLegacySpace(userId);
}
},