From 4668aad039abc4dfb0bead266d86e7f4e50c8ad3 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:16:51 +0800 Subject: [PATCH] =?UTF-8?q?refactor(core):=20remove=20platform=20coupling?= =?UTF-8?q?=20=E2=80=94=20StorageAdapter,=20sonner,=20barrel=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0: Replace all localStorage calls in packages/core with StorageAdapter - Create StorageAdapter interface (getItem/setItem/removeItem) - Auth store factory now requires storage parameter - Workspace store factory accepts optional storage parameter - WSProvider accepts storage prop for token retrieval - apps/web/platform/ passes localStorage as the web implementation P1: Remove sonner UI dependency from packages/core - Replace toast.error() in workspace store with onError callback - Move sonner import to apps/web/platform/workspace.ts - Remove sonner from packages/core/package.json dependencies P2: Delete 5 pure re-export barrel files in apps/web/features/ - features/issues/index.ts, modals/index.ts, navigation/index.ts, workspace/index.ts, inbox/index.ts — all had zero consumers - features/ now only contains auth/ (web-only cookie + initializer) and landing/ (web-only pages) Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/features/inbox/index.ts | 13 ------------- apps/web/features/issues/index.ts | 5 ----- apps/web/features/modals/index.ts | 2 -- apps/web/features/navigation/index.ts | 1 - apps/web/features/workspace/index.ts | 3 --- apps/web/platform/auth.ts | 1 + apps/web/platform/workspace.ts | 6 +++++- apps/web/platform/ws-provider.tsx | 1 + packages/core/auth/store.ts | 15 ++++++++------- packages/core/package.json | 3 +-- packages/core/realtime/provider.tsx | 9 ++++++--- packages/core/types/index.ts | 1 + packages/core/types/storage.ts | 5 +++++ packages/core/workspace/store.ts | 23 +++++++++++++++-------- 14 files changed, 43 insertions(+), 45 deletions(-) delete mode 100644 apps/web/features/inbox/index.ts delete mode 100644 apps/web/features/issues/index.ts delete mode 100644 apps/web/features/modals/index.ts delete mode 100644 apps/web/features/navigation/index.ts delete mode 100644 apps/web/features/workspace/index.ts create mode 100644 packages/core/types/storage.ts diff --git a/apps/web/features/inbox/index.ts b/apps/web/features/inbox/index.ts deleted file mode 100644 index d8f4e5cab..000000000 --- a/apps/web/features/inbox/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Inbox server state is managed by TanStack Query. -// See core/inbox/ for queries, mutations, and WS updaters. -export { - inboxKeys, - inboxListOptions, - deduplicateInboxItems, - useMarkInboxRead, - useArchiveInbox, - useMarkAllInboxRead, - useArchiveAllInbox, - useArchiveAllReadInbox, - useArchiveCompletedInbox, -} from "@multica/core/inbox"; diff --git a/apps/web/features/issues/index.ts b/apps/web/features/issues/index.ts deleted file mode 100644 index 7a8708ebb..000000000 --- a/apps/web/features/issues/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { useIssueStore } from "@multica/core/issues"; -export { useIssueViewStore, createIssueViewStore } from "@multica/core/issues/stores/view-store"; -export { ViewStoreProvider, useViewStore } from "@multica/core/issues/stores/view-store-context"; -export { StatusIcon, PriorityIcon, StatusPicker, PriorityPicker, AssigneePicker } from "@multica/views/issues/components"; -export * from "@multica/core/issues/config"; diff --git a/apps/web/features/modals/index.ts b/apps/web/features/modals/index.ts deleted file mode 100644 index 1e3f7f690..000000000 --- a/apps/web/features/modals/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { useModalStore } from "@multica/core/modals"; -export { ModalRegistry } from "@multica/views/modals/registry"; diff --git a/apps/web/features/navigation/index.ts b/apps/web/features/navigation/index.ts deleted file mode 100644 index 32364285c..000000000 --- a/apps/web/features/navigation/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useNavigationStore } from "@multica/core/navigation"; diff --git a/apps/web/features/workspace/index.ts b/apps/web/features/workspace/index.ts deleted file mode 100644 index d04bb92ee..000000000 --- a/apps/web/features/workspace/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { useWorkspaceStore } from "@/platform/workspace"; -export { useActorName } from "@multica/core/workspace/hooks"; -export { WorkspaceAvatar } from "@multica/views/workspace/workspace-avatar"; diff --git a/apps/web/platform/auth.ts b/apps/web/platform/auth.ts index 50f607292..1e79c220b 100644 --- a/apps/web/platform/auth.ts +++ b/apps/web/platform/auth.ts @@ -7,6 +7,7 @@ import { export const useAuthStore = createAuthStore({ api, + storage: localStorage, onLogin: setLoggedInCookie, onLogout: clearLoggedInCookie, }); diff --git a/apps/web/platform/workspace.ts b/apps/web/platform/workspace.ts index 3c16a6d83..1551080aa 100644 --- a/apps/web/platform/workspace.ts +++ b/apps/web/platform/workspace.ts @@ -1,6 +1,10 @@ import { createWorkspaceStore, registerWorkspaceStore } from "@multica/core/workspace"; +import { toast } from "sonner"; import { api } from "./api"; -export const useWorkspaceStore = createWorkspaceStore(api); +export const useWorkspaceStore = createWorkspaceStore(api, { + storage: localStorage, + onError: (msg) => toast.error(msg), +}); registerWorkspaceStore(useWorkspaceStore); diff --git a/apps/web/platform/ws-provider.tsx b/apps/web/platform/ws-provider.tsx index 3cd62dce4..90ff14b2a 100644 --- a/apps/web/platform/ws-provider.tsx +++ b/apps/web/platform/ws-provider.tsx @@ -13,6 +13,7 @@ export function WebWSProvider({ children }: { children: React.ReactNode }) { wsUrl={WS_URL} authStore={useAuthStore} workspaceStore={useWorkspaceStore} + storage={localStorage} onToast={(message, type) => { if (type === "error") toast.error(message); else toast.info(message); diff --git a/packages/core/auth/store.ts b/packages/core/auth/store.ts index bca9baf87..bc534c9a8 100644 --- a/packages/core/auth/store.ts +++ b/packages/core/auth/store.ts @@ -1,9 +1,10 @@ import { create } from "zustand"; -import type { User } from "../types"; +import type { User, StorageAdapter } from "../types"; import type { ApiClient } from "../api/client"; export interface AuthStoreOptions { api: ApiClient; + storage: StorageAdapter; onLogin?: () => void; onLogout?: () => void; } @@ -21,14 +22,14 @@ export interface AuthState { } export function createAuthStore(options: AuthStoreOptions) { - const { api, onLogin, onLogout } = options; + const { api, storage, onLogin, onLogout } = options; return create((set) => ({ user: null, isLoading: true, initialize: async () => { - const token = localStorage.getItem("multica_token"); + const token = storage.getItem("multica_token"); if (!token) { set({ isLoading: false }); return; @@ -42,7 +43,7 @@ export function createAuthStore(options: AuthStoreOptions) { } catch { api.setToken(null); api.setWorkspaceId(null); - localStorage.removeItem("multica_token"); + storage.removeItem("multica_token"); set({ user: null, isLoading: false }); } }, @@ -53,7 +54,7 @@ export function createAuthStore(options: AuthStoreOptions) { verifyCode: async (email: string, code: string) => { const { token, user } = await api.verifyCode(email, code); - localStorage.setItem("multica_token", token); + storage.setItem("multica_token", token); api.setToken(token); onLogin?.(); set({ user }); @@ -62,7 +63,7 @@ export function createAuthStore(options: AuthStoreOptions) { loginWithGoogle: async (code: string, redirectUri: string) => { const { token, user } = await api.googleLogin(code, redirectUri); - localStorage.setItem("multica_token", token); + storage.setItem("multica_token", token); api.setToken(token); onLogin?.(); set({ user }); @@ -70,7 +71,7 @@ export function createAuthStore(options: AuthStoreOptions) { }, logout: () => { - localStorage.removeItem("multica_token"); + storage.removeItem("multica_token"); api.setToken(null); api.setWorkspaceId(null); onLogout?.(); diff --git a/packages/core/package.json b/packages/core/package.json index 8b5dc7dea..479d08958 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -45,8 +45,7 @@ "dependencies": { "@tanstack/react-query": "catalog:", "@tanstack/react-query-devtools": "^5.96.2", - "zustand": "catalog:", - "sonner": "^2.0.0" + "zustand": "catalog:" }, "peerDependencies": { "react": "catalog:" diff --git a/packages/core/realtime/provider.tsx b/packages/core/realtime/provider.tsx index bc1081517..44917cfe9 100644 --- a/packages/core/realtime/provider.tsx +++ b/packages/core/realtime/provider.tsx @@ -10,7 +10,7 @@ import { type ReactNode, } from "react"; import { WSClient } from "../api/ws-client"; -import type { WSEventType } from "../types"; +import type { WSEventType, StorageAdapter } from "../types"; import type { StoreApi, UseBoundStore } from "zustand"; import type { AuthState } from "../auth/store"; import type { WorkspaceStore } from "../workspace/store"; @@ -34,6 +34,8 @@ export interface WSProviderProps { authStore: UseBoundStore>; /** Platform-created workspace store instance */ workspaceStore: UseBoundStore>; + /** Platform-specific storage adapter for reading auth tokens */ + storage: StorageAdapter; /** Optional callback for showing toast messages (platform-specific, e.g. sonner) */ onToast?: (message: string, type?: "info" | "error") => void; } @@ -43,6 +45,7 @@ export function WSProvider({ wsUrl, authStore, workspaceStore, + storage, onToast, }: WSProviderProps) { const user = authStore((s) => s.user); @@ -53,7 +56,7 @@ export function WSProvider({ useEffect(() => { if (!user || !workspace) return; - const token = localStorage.getItem("multica_token"); + const token = storage.getItem("multica_token"); if (!token) return; const ws = new WSClient(wsUrl, { logger: createLogger("ws") }); @@ -67,7 +70,7 @@ export function WSProvider({ wsRef.current = null; setWsClient(null); }; - }, [user, workspace, wsUrl]); + }, [user, workspace, wsUrl, storage]); const stores: RealtimeSyncStores = { authStore, workspaceStore }; diff --git a/packages/core/types/index.ts b/packages/core/types/index.ts index 962f255a7..7838d7c78 100644 --- a/packages/core/types/index.ts +++ b/packages/core/types/index.ts @@ -29,3 +29,4 @@ export type { IssueSubscriber } from "./subscriber"; export type * from "./events"; export type * from "./api"; export type { Attachment } from "./attachment"; +export type { StorageAdapter } from "./storage"; diff --git a/packages/core/types/storage.ts b/packages/core/types/storage.ts new file mode 100644 index 000000000..514d5a60e --- /dev/null +++ b/packages/core/types/storage.ts @@ -0,0 +1,5 @@ +export interface StorageAdapter { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} diff --git a/packages/core/workspace/store.ts b/packages/core/workspace/store.ts index 9b4c12b6c..a2a2c84a3 100644 --- a/packages/core/workspace/store.ts +++ b/packages/core/workspace/store.ts @@ -1,11 +1,15 @@ import { create } from "zustand"; -import { toast } from "sonner"; -import type { Workspace } from "../types"; +import type { Workspace, StorageAdapter } from "../types"; import type { ApiClient } from "../api/client"; import { createLogger } from "../logger"; const logger = createLogger("workspace-store"); +interface WorkspaceStoreOptions { + storage?: StorageAdapter; + onError?: (message: string) => void; +} + interface WorkspaceState { workspace: Workspace | null; workspaces: Workspace[]; @@ -31,7 +35,10 @@ interface WorkspaceActions { export type WorkspaceStore = WorkspaceState & WorkspaceActions; -export function createWorkspaceStore(api: ApiClient) { +export function createWorkspaceStore(api: ApiClient, options?: WorkspaceStoreOptions) { + const storage = options?.storage; + const onError = options?.onError; + return create((set, get) => ({ // State workspace: null, @@ -50,13 +57,13 @@ export function createWorkspaceStore(api: ApiClient) { if (!nextWorkspace) { api.setWorkspaceId(null); - localStorage.removeItem("multica_workspace_id"); + storage?.removeItem("multica_workspace_id"); set({ workspace: null }); return null; } api.setWorkspaceId(nextWorkspace.id); - localStorage.setItem("multica_workspace_id", nextWorkspace.id); + storage?.setItem("multica_workspace_id", nextWorkspace.id); set({ workspace: nextWorkspace }); logger.debug("hydrate workspace", nextWorkspace.name, nextWorkspace.id); @@ -73,7 +80,7 @@ export function createWorkspaceStore(api: ApiClient) { if (!ws) return; api.setWorkspaceId(ws.id); - localStorage.setItem("multica_workspace_id", ws.id); + storage?.setItem("multica_workspace_id", ws.id); // All data caches (issues, inbox, members, agents, skills, runtimes) // are managed by TanStack Query, keyed by wsId — auto-refetch on switch. @@ -84,14 +91,14 @@ export function createWorkspaceStore(api: ApiClient) { refreshWorkspaces: async () => { const { workspace, hydrateWorkspace } = get(); - const storedWorkspaceId = localStorage.getItem("multica_workspace_id"); + const storedWorkspaceId = storage?.getItem("multica_workspace_id") ?? null; try { const wsList = await api.listWorkspaces(); hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId); return wsList; } catch (e) { logger.error("failed to refresh workspaces", e); - toast.error("Failed to refresh workspaces"); + onError?.("Failed to refresh workspaces"); return get().workspaces; } },