refactor(core): remove platform coupling — StorageAdapter, sonner, barrel cleanup

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) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-04-09 13:16:51 +08:00
parent f41a0cf423
commit 4668aad039
14 changed files with 43 additions and 45 deletions

View File

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

View File

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

View File

@@ -1,2 +0,0 @@
export { useModalStore } from "@multica/core/modals";
export { ModalRegistry } from "@multica/views/modals/registry";

View File

@@ -1 +0,0 @@
export { useNavigationStore } from "@multica/core/navigation";

View File

@@ -1,3 +0,0 @@
export { useWorkspaceStore } from "@/platform/workspace";
export { useActorName } from "@multica/core/workspace/hooks";
export { WorkspaceAvatar } from "@multica/views/workspace/workspace-avatar";

View File

@@ -7,6 +7,7 @@ import {
export const useAuthStore = createAuthStore({
api,
storage: localStorage,
onLogin: setLoggedInCookie,
onLogout: clearLoggedInCookie,
});

View File

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

View File

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

View File

@@ -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<AuthState>((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?.();

View File

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

View File

@@ -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<StoreApi<AuthState>>;
/** Platform-created workspace store instance */
workspaceStore: UseBoundStore<StoreApi<WorkspaceStore>>;
/** 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 };

View File

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

View File

@@ -0,0 +1,5 @@
export interface StorageAdapter {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}

View File

@@ -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<WorkspaceStore>((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;
}
},