跳到主要内容

多端前端:共享包分层、服务态 vs 客户态、平台适配

30 秒导读: Multica 要在网页、桌面(Electron)、手机(React Native)三端上,给同一套"issue / agent / chat"业务复用同一份代码。它的做法是把业务逻辑抽成三个共享包(ui/core/views),用单向依赖防止它们互相污染;把状态切成服务态(TanStack Query)客户态(Zustand) 两半分别托管;再用一层薄薄的平台适配层吸收 Next.js / react-router / Electron 的差异。手机端是特例——它只借类型和纯函数,UI 全自己写。

本章讲前端怎么组织代码,不讲某个页面怎么实现。读完你应该能回答:一段业务逻辑该放哪个包?一个状态该归 Query 还是 Zustand?web 和 desktop 怎么用同一个 <AppLink> 却跳到不同地方?

同组其它章按需相对链接:运行时抽象见 01-agent-runtime,任务状态机见 03-task-dispatch-lifecycle,两套 WebSocket 通道见 04-realtime-protocol;全景与阅读地图见 index


1. 这是什么(零基础也能懂)

一句话定义: 一个 pnpm monorepo,里面 4 个「客户端」(web / desktop / mobile / CLI)复用同一份「业务大脑」。

要解决的问题: 假设你已经写好了网页版的"看板拖动 issue"逻辑——调哪个 API、乐观更新怎么做、失败怎么回滚。现在产品要出桌面 App 和手机 App。最蠢的做法是复制三份,以后改一个 bug 要改三处、还会漏。Multica 的目标是:业务逻辑只写一次,三端共用;每端只写自己独有的那点"外壳"(桌面的多标签窗口、手机的触屏交互)。

它由哪些部分组成: 一张表看懂顶层布局(依据:pnpm-workspace.yaml、根 CLAUDE.md "Project Shape")。

目录角色属于哪端
packages/ui原子 UI 组件(按钮、头像、表格)共享
packages/core无头业务大脑(API client、Query hooks、Zustand store)共享
packages/views共享业务页面(issue 列表、chat 窗口)共享(web+desktop)
apps/webNext.js 网页壳web
apps/desktopElectron + react-router 桌面壳desktop
apps/mobileExpo / React Native 手机壳mobile(独立)
server/cmd/multicaGo 写的命令行客户端CLI(第四种客户端)

一句话直觉: 把它想成一家餐厅。core后厨(菜怎么做),ui餐具(盘子刀叉),views摆好的成品菜;apps/webapps/desktop 是两个不同门面的店面,共用同一个后厨和成品菜;apps/mobile加盟店——只从总部拿菜谱(类型定义),厨房自己重开一个。

本节不碰代码细节。记住一件事:共享靠"分层 + 单向依赖",隔离靠"边界规则"。下面逐层拆。


2. 顶层全景(它大概怎么转)

2.1 依赖方向:只能从上往下指

三个共享包不是平级随便引用,而是一条单向链。这是整套架构不塌的地基:如果 core 反过来引 views,业务逻辑就被 UI 绑死,手机端再也拆不出来。

怎么读下图:箭头 = "可以 import";没有箭头 = 禁止。方向从具体(views)指向抽象(core/ui),绝不反向、绝不成环。

apps/web (Next.js) apps/desktop (Electron+react-router)
│ │
│ 平台适配层注入 NavigationAdapter │
▼ ▼
┌─────────────────────────────────────────┐
│ packages/views (共享业务页面) │ ← 禁 next/* 、禁 react-router-dom、禁 store
└───────────────┬──────────────┬───────────┘
│ │
▼ ▼
┌────────────────┐ ┌──────────────┐
│ packages/core │ │ packages/ui │ ← ui 禁引 core、禁业务逻辑
│ (业务大脑/状态) │ │ (原子组件) │
└────────────────┘ └──────────────┘

│ 只 import type + 纯函数

apps/mobile (React Native,独立)

部件一句话职责(依据:根 CLAUDE.md "Package Boundaries";各 package.json):

干什么硬禁忌
packages/ui原子、无业务的展示组件不许 import @multica/core,不许含业务逻辑
packages/core无头逻辑:API client、Query hooks、Zustand store不许 react-domlocalStorageprocess.env、UI 库
packages/views拼装 core+ui 的共享业务页面不许 next/*react-router-dom、不许放 store
apps/web/platform唯一能用 Next.js 导航 API 的地方——
apps/desktop/.../platform唯一能接 react-router-dom 导航的地方——

packages/views 的依赖里确实只有 @multica/core@multica/ui 两个 workspace 包,没有任何 nextreact-router(依据:packages/views/package.jsondependencies)。packages/coredependencies 里只有 zustand / @tanstack/react-query / zod 等,react 只作为 peerDependencies,没有 react-dom(依据:packages/core/package.json)。

2.2 状态二分:一半归 Query,一半归 Zustand

前端状态被劈成两个世界,各有唯一主人。混淆二者是最常见的架构腐坏来源,所以 Multica 把它写成硬规则(依据:根 CLAUDE.md "State Rules")。

服务态(Server state)客户态(Client state)
主人TanStack QueryZustand
装什么issues、users、workspaces、agents、inbox——凡是从 API 拉来的过滤器、草稿、弹窗开关、标签页布局
特征后端是唯一真相,可能过期,要缓存/失效/重取前端自己说了算,不需要服务器确认
存哪Query cache(内存)Zustand,少数持久化到本地

一句话判断归属:"刷新页面后还要跟服务器对齐的" = 服务态;"纯粹是这台设备上你此刻的操作偏好" = 客户态

2.3 主线走一遍:一次"改 issue 状态"

不进代码,先看数据在三层里怎么流(以 web 拖动看板卡片为例):

用户拖卡片 → views 里的看板组件调 core 的 useUpdateIssue()(mutation)
→ core 立刻乐观改写 Query cache(卡片瞬间挪位)
→ core 把请求发给 API client → 服务器
→ 成功:用服务器返回值做一次"外科手术式"补丁对齐
失败:回滚 cache,卡片弹回原位
→ 另一端/另一台设备通过 WebSocket 收到事件 → 让对应 Query 失效/打补丁

同一段 useUpdateIssue 代码,web 和 desktop 一字不改地共用;区别只在"卡片挪位"这个动画由各自平台的渲染承担。下面各节把这条线拆开讲。


3. 核心原理

3.1 包分层:依赖方向靠"没声明就引不到"来物理保证

要解决的小问题: 光在文档里写"views 不许引 next"没用,人总会手滑。怎么让违规编译/lint 就报错?

思路: Multica 用两道机器闸门,而不是靠自觉。

第一道是 pnpm workspace + package.json 白名单。每个包必须在自己的 package.json 里声明它直接 import 的外部依赖;ESLint 的 import-x/no-extraneous-dependencies 规则把"引用了没声明的包"判为 error(依据:packages/eslint-config/base.js:19 的规则块)。packages/views/package.json 根本没把 next 写进依赖,于是 views 里写 import ... from "next/navigation" 会直接被拦——引不到,不是不想引

第二道是桌面端的 no-restricted-imports 显式黑名单。桌面应用代码(除 src/platform 外)禁止从 react-router-domuseNavigate / Navigate,也禁止直接调 router.navigate——因为桌面的导航必须走"标签协调器"(Coordinator)协议(依据:apps/desktop/eslint.config.mjs:55 起的 no-restricted-importsno-restricted-syntax 规则,注释标 MUL-4741)。

教学示意——这两道闸门的效果(# 示意,非源码):

// 在 packages/views/ 里:
import { useRouter } from "next/navigation"; // ✗ lint error: next 不在 views 的 package.json
import { useNavigation } from "./navigation"; // ✓ 用抽象适配器

// 在 packages/ui/ 里:
import { useUpdateIssue } from "@multica/core"; // ✗ ui 禁引 core(原子组件不许有业务)

关键细节: uicore 必须互相独立——ui 不引 core,core 不引任何 UI 库。这保证了两件事:ui 可以被任何没有业务上下文的地方复用;core 可以被没有 DOM 的环境(比如手机、甚至测试)加载。

3.2 服务态:TanStack Query,query key 必带 wsId

要解决的小问题: Multica 是多工作区(workspace)产品,同一个用户可能同时开着 A、B 两个工作区。issue 列表的缓存必须按工作区隔离,否则切工作区会串数据。

思路:wsId(工作区 UUID)焊进每个工作区级 query key 的最前面。规则明写:"Workspace-scoped query keys must include wsId"(依据:根 CLAUDE.md "State Rules")。

真实实现: issueKeys 是一棵以 wsId 为根的 key 工厂树——all(wsId)["issues", wsId],其它所有 key(list / flat / table / detail)都从它派生(依据:packages/core/issues/queries.ts:33issueKeys)。

// packages/core/issues/queries.ts:33 附近 —— issueKeys
export const issueKeys = {
all: (wsId: string) => ["issues", wsId] as const,
list: (wsId: string) => [...issueKeys.all(wsId), "list"] as const,
// ...listSorted / flat / tableRows / detail 全部带上 wsId
};

这套设计的好处:失效(invalidate)可以按前缀批量做。想刷新某工作区的所有 issue 列表,invalidateQueries({ queryKey: issueKeys.list(wsId) }) 一句话命中该工作区下所有排序变体,却不碰另一个工作区的缓存。

wsId 从哪来? 组件不自己传,而是调 useWorkspaceId();这个 hook 从"当前 URL 的 slug + 工作区列表"里推出 UUID(依据:packages/core/hooks.tsx:13 useWorkspaceId)。注意这里有一处文档与代码的漂移:根 CLAUDE.md 仍把它描述成 WorkspaceIdProvider(React Context),但源码注释明说该 Provider 已被移除,改为 slug-first——由 useCurrentWorkspace() 用 URL slug 去 React Query 的工作区列表里查(依据:packages/core/hooks.tsx:9 注释、packages/core/paths/hooks.tsx:56 useCurrentWorkspace)。以源码为准。

3.3 客户态:Zustand,只装"这台设备此刻的偏好"

要解决的小问题: 过滤器选了哪些状态、草稿写了一半、哪个弹窗开着——这些不该发给服务器,也不该塞进 Query cache。

思路: 用 Zustand 单独托管。共享 store 一律放在 packages/core/,绝不放在 views 或 app 目录(依据:根 CLAUDE.md "State Rules")。

真实实现: issue 视图的过滤/排序/分组状态就是一个典型客户态 store——IssueViewState 里全是 statusFilterspriorityFiltersgroupingsortBy 这类纯前端选择(依据:packages/core/issues/stores/view-store.ts:147 IssueViewState;store 在 :584 useIssueViewStorecreate() + persist 中间件建立)。

巧妙的持久化取舍: 不是所有客户态都值得持久化。agentRunningFilter(只看"当前有 agent 在跑"的 issue)故意不持久化——因为运行状态每秒都在变,持久化会让用户下次回来看到一个空列表却不知道为什么(依据:view-store.ts:147 区块内该字段注释,及 :472 起 persist 的 partialize/merge 配置)。规则:持久耐久偏好/草稿/布局;不持久服务数据和易变 UI 态

一条容易踩的坑: Zustand selector 必须返回稳定引用。selector 里当场 map/filter 出一个新数组,会让组件每次都重渲染;需要派生就配 useShallow 或浅比较(依据:根 CLAUDE.md "State Rules" 明列此条)。

3.4 乐观更新:四条同时成立才能做

要解决的小问题: 点一下"标记完成",要不要等服务器回来再变 UI?等,则卡顿;不等,则要处理失败回滚。

思路: Multica 不无脑乐观。它给出四条必须同时成立的门槛,缺一条就老实等服务器(依据:根 CLAUDE.md "State Rules" 的 optimistic 条目):

  1. 结果本地可预测(你知道点完会变成什么样);
  2. 用户停在同一屏(不发生跳转);
  3. 失败很罕见;
  4. 回滚很容易

典型合格场景:改状态 / 改指派 / 切换某个布尔字段——**"打补丁 → 失败回滚 → 落定后让不确定的投影失效"**三段式。

真实实现: useUpdateIssue 就是教科书式的三段(依据:packages/core/issues/mutations.ts:231 useUpdateIssue):

  • onMutate:同步打补丁。它先 cancelQueries故意不 await——保持同步,让 cache 更新和 mutate() 落在同一 tick,否则 @dnd-kit 会先把拖动的视觉状态复位(依据:mutations.ts:246 注释)。快照存进 context 供回滚。
  • onError:用快照 rollbackIssueChange 回滚,卡片弹回原位(依据:mutations.ts:314)。
  • onSettled:让"可能漂移的投影"失效重取,但自己那张 list/detail 不在这里失效——它已在 onSuccess 用服务器返回值做过外科手术式补丁,再失效会导致成功的一次移动闪一下(依据:mutations.ts:325 onSuccess 注释、:358 onSettled 注释)。

反例边界(何时不能乐观): 创建、删除、离开工作区这类会跳转或需要确认的流程,必须先等服务器再跳转/清理,绝不能乐观地把实体从 cache 里删掉(依据:根 CLAUDE.md "State Rules")。原因:第 2 条(停在同一屏)不成立——一旦跳走,回滚就没有"原位"可弹。

3.5 pending-message 模式:聊天发送不用"静默乐观"

要解决的小问题: 聊天发消息既要"手感即时"(消息马上出现),又不能假装成功(万一失败,用户以为发出去了)。

思路:pending-message 模式,和第 3.4 的乐观更新刻意区分:消息立刻渲染,但带一个可见的 pending 状态;失败不是静默回滚,而是把文本退回给用户以便重试(依据:根 CLAUDE.md "State Rules" 的 chat 条目)。

真实实现: 发送时先造一条 idoptimistic- 开头的临时消息塞进 cache,同时写一条 pendingTask(status: "queued"),让 UI 立刻显示"发送中"(依据:packages/views/chat/components/use-chat-controller.ts:503 起,optimistic 消息与 :517 pendingTask)。请求失败时,把这条临时消息从各 cache 里移除、清掉 pendingTask,并 enqueueLocalRestore 把内容和附件塞回"本地恢复"队列,composer 会把草稿还给用户(依据:use-chat-controller.ts:536545 的 catch 分支)。

// packages/views/chat/components/use-chat-controller.ts:503 附近 —— 示意结构
const optimistic = { id: `optimistic-${Date.now()}`, role: "user", content, ... };
appendChatMessageToLatestPageCache(qc, sessionId, optimistic); // 立刻上屏
try {
await api.sendChatMessage(sessionId, content, attachmentIds);
} catch (err) {
removeChatMessageFromCaches(qc, sessionId, optimistic.id); // 撤下
enqueueLocalRestore({ content, attachments, sessionId }); // 退回草稿,可重试
}

为什么不直接用 3.4 的乐观回滚? 因为聊天发送的第 3 条(失败罕见)不够硬——网络抖动、权限中途被撤(结构化 403)都会失败;把失败做成"可见 + 可重试"比"静默弹回"对用户更诚实。

3.6 WebSocket 事件:只喂 Query cache,不镜像进 Zustand

要解决的小问题: 另一台设备改了 issue,本端要同步。WS 事件来了,数据往哪放?

思路: WS 事件只用来让 Query cache 失效或打补丁,绝不把服务器 payload 镜像进 Zustand(依据:根 CLAUDE.md "State Rules")。因为服务数据的唯一主人是 Query(见 3.2),Zustand 若也存一份就有了两个真相。

真实实现: issue 的 ws-updater 就是往 Query cache 里写——onIssueCreated 把新 issue 塞进列表 cache 并让相关聚合失效(依据:packages/core/issues/ws-updaters.ts:115 onIssueCreated,内部 setQueryData + 一串 invalidateQueries)。允许 WS 清理的唯一例外是"客户端自己拥有的指针"(当前会话、选中项、当前工作区),且要有单一响应者 + 自发防抖 guard。两套 WS 通道的机制细节见 04-realtime-protocol


4. 平台适配层:同一个 <AppLink>,三种落地

这是"共享而不污染"的关键机械。views 里的共享代码想导航,但它不许碰 next 也不许碰 react-router。怎么办?——依赖倒置:views 只依赖一个抽象接口 NavigationAdapter,由每个 app 在自己的平台层注入具体实现。

4.1 抽象:NavigationAdapter 接口

views 定义了导航需要的能力,但不关心谁实现——push / replace / back / pathname / searchParams,外加桌面才有的 openInNewTab(依据:packages/views/navigation/types.ts:1 NavigationAdapter)。共享代码通过 useNavigation() 拿到当前平台注入的适配器,或直接用 <AppLink> 组件(依据:packages/views/navigation/index.ts 导出、packages/views/navigation/app-link.tsx:15 AppLink)。

<AppLink> 自己不认识路由,它把点击翻译成适配器调用:普通点击 → push(href);按住 cmd/ctrl/shift → 若适配器提供了 openInNewTab 就开新标签,否则放行给浏览器原生行为(依据:app-link.tsx:2249handleClick)。

4.2 web 的落地:Next.js router

网页端在 apps/web/platform/navigation.tsx 把 Next 的 useRouter/usePathname/useSearchParams 包成一个 NavigationAdapter,push 直接就是 router.push,prefetch 接到 router.prefetch 预热 RSC(依据:apps/web/platform/navigation.tsx:39adapter)。这是唯一允许出现 next/navigation 的层。

4.3 desktop 的落地:标签协调器,不是路由

桌面端复杂得多,因为它有多标签窗口。它的适配器 push 根本不直接调路由,而是把导航翻译成"操作标签会话",再由协调器(Coordinator)把那个单一 router 对齐到活动标签的 URL(依据:apps/desktop/src/renderer/src/platform/navigation.tsx:158 DesktopNavigationProvider)。

怎么读下面这条判定链——一次 push(path) 依次问四个问题,命中即停:

push(path)

├─ 是 /login ? → 走登出,return
├─ 是过渡流程(新建工作区/邀请)? → 开 WindowOverlay,return (tryRouteToOverlay)
├─ 目标 slug ≠ 当前工作区? → 交给标签组切换,return (tryRouteToOtherWorkspace)
├─ 当前是固定标签(pinned)? → 强制开新标签,return (tryRouteToPinnedNewTab)
└─ 都不是 → navigateActiveSession(path) 在当前标签内跳

跨工作区导航被单独拦下来交给 switchWorkspace(targetSlug, path),正是这一步让"侧栏切工作区、cmd+K 切工作区、删除后重定向"这些共享代码在桌面上自动变成"切换标签组"(依据:navigation.tsx:89 tryRouteToOtherWorkspace:186 调用点)。

过渡流程 vs 会话路由(桌面三类路由): 桌面把"新建工作区、接受邀请"这类一次性的、进工作区之前的流程做成 WindowOverlay 状态而不是路由(依据:navigation.tsx:48 tryRouteToOverlay + apps/desktop/.../stores/window-overlay-store.ts;根 CLAUDE.md "Desktop Rules")。会话路由才是工作区内的标签目的地如 /:slug/issues

<DragStrip />(Electron 无边框窗口的可拖动条)也在这一层:仪表盘外壳之外的全窗口视图必须把它作为第一个 flex 子节点挂上,顶部 48px 内的交互控件要标 WebkitAppRegion: "no-drag"(依据:根 CLAUDE.md "Desktop Rules")。

4.4 setCurrentWorkspace:只镜像路由标识,不是真相

工作区的真相是路由(URL slug),但有些地方拿不到 React 上下文却又要知道当前工作区:HTTP 头 X-Workspace-ID、本地存储命名空间、WebSocket 重连。为此有一个模块级单例镜像。

setCurrentWorkspace(slug, uuid) 把当前工作区镜像给这些消费者,由工作区路由布局负责调用(web 在 apps/web/app/[workspaceSlug]/layout.tsx:59,desktop 在 workspace-route-layout.tsx:69)。它只镜像标识,不存业务数据;slug 没变时直接短路返回,变了才用 queueMicrotask 通知订阅者并触发本地存储的 rehydrate(依据:packages/core/platform/workspace-storage.ts:34 setCurrentWorkspace)。

离开工作区必须显式清空: 登出、切到无工作区的界面时要调 setCurrentWorkspace(null, null),否则镜像会残留旧工作区(依据:packages/core/auth/store.ts:71apps/desktop/src/renderer/src/App.tsx:233 的调用)。这也呼应了"只有 auth/workspace store 才允许直接调 api.*"的例外(依据:根 CLAUDE.md "State Rules")。


5. API 兼容:parseWithFallback + zod 扛后端漂移

要解决的小问题: 桌面 App 是装在用户机器上的,可能好几个版本没更新,却在和一个更新过的后端说话。后端字段一改,旧客户端不能白屏崩溃。

思路: 网络 JSON 绝不直接 as T 硬转,而是过一道 zod schema;校验失败不抛异常,回退到一个安全默认值,同时打一条带 endpoint 的告警日志(依据:根 CLAUDE.md "API Compatibility"、packages/core/api/schema.ts:38 parseWithFallback)。

真实实现: parseWithFallback(data, schema, fallback, {endpoint})schema.safeParse,成功返回数据,失败记 logger.warn 并返回 fallback——把"API 契约漂移"从"白屏事故"降级成"能渲染但降级"的页面(依据:schema.ts:3855)。

// packages/core/api/client.ts:537 附近 —— getMe(),真实调用点
async getMe(): Promise<User> {
const raw = await this.fetch<unknown>("/api/me");
return parseWithFallback(raw, UserSchema, EMPTY_USER, { endpoint: "GET /api/me" });
}

配套的防御纪律(依据:根 CLAUDE.md "API Compatibility"):

  • schema 故意宽松——枚举用 z.string() 而非严格 union,这样后端新增一个枚举值也不会整条校验失败(依据:schema.ts:1934 的类型注释、packages/core/api/schemas.ts:214 注释)。
  • 下游 UI 要防御性地可选链 + 给默认值;对服务器布尔字段用 === true 显式判断,别用 truthy;枚举 switch 必须带 default 分支。
  • 新增/改动 endpoint 时,同 PR 要加/改 schema 并加一个畸形响应测试

6. mobile 的独立性:只借类型和纯函数

手机端是这套架构里唯一不共享 UI/状态的客户端。它只从 @multica/core 拿两样东西:类型定义(用 import type,零运行时耦合)和纯函数;其余的 UI、状态、hooks、providers、i18n、React 版本、构建管线、发版节奏全部自己拥有(依据:根 CLAUDE.md "Sharing Rules"、apps/mobile/CLAUDE.md:610)。

代码印证:apps/mobile 里对 core 的引用清一色是 import type { ... } from "@multica/core/types"——switch-workspace.tsx:31my-issues.tsx:23inbox.tsx:11 等等,没有引 store/hooks/组件(依据:grep @multica/core apps/mobile);它在 package.json:24 声明 @multica/core 依赖,但版本自己钉 Expo/RN 相关包,不走根 catalog:

为什么这么切? 手机的交互范式和桌面/网页差太远(触屏、原生导航、离线),共享 UI 反而是负担;但产品语义必须一致——计数、权限、枚举/状态流转、数据身份得和 web/desktop 对齐(靠共享类型和纯函数保证),UI 可以按手机场景不同(依据:根 CLAUDE.md "Mobile Rules";apps/mobile/CLAUDE.md 的 pre-flight 要求先读 web 实现再抄语义)。

第四种客户端——CLI: Go 写的命令行客户端 server/cmd/multica(如 cmd_issue.gocmd_chat.go)是完全独立的一端,直接打后端 HTTP/WS,与前端共享包无代码关系,只共享 API 契约(依据:server/cmd/multica/)。运行时与守护进程细节见 01-agent-runtime02-local-daemon


7. 巧妙之处(可借鉴的技术)

  • 用 package.json 白名单当架构护栏。 不靠自觉、不靠约定,靠"没在 package.json 声明就 lint 报错"物理阻断跨层引用——import-x/no-extraneous-dependencies(依据:packages/eslint-config/base.js:19)。这比写规范文档可靠得多。
  • 同步的乐观补丁。 onMutate 故意不 await cancelQueries,把 cache 更新压进和 mutate() 同一 tick,专门为了不和拖拽库(@dnd-kit)的视觉复位打架(依据:packages/core/issues/mutations.ts:246)。这种"为动画时序而放弃 await"的取舍很少见。
  • pending-message ≠ 乐观更新。 同一个团队对"改状态"用静默乐观回滚,对"发消息"用可见 pending + 退回草稿——因为两者的"失败罕见度"和"回滚代价"不同(依据:use-chat-controller.ts:503+536)。区分粒度值得学。
  • 宽松 schema + 硬回退。 枚举留 z.string() 让未知值也能过,校验失败回退安全默认而非抛错——为"装机版桌面客户端遇上新后端"这个具体场景设计(依据:packages/core/api/schema.ts:38)。
  • 依赖倒置吸收平台差异。 一个 NavigationAdapter 接口,web 落成 router.push、desktop 落成"操作标签会话",共享代码一行不改(依据:packages/views/navigation/types.ts:1 + 两个平台的 navigation.tsx)。

8. 边界与局限(诚实)

  • 文档已漂移于代码。CLAUDE.md 仍称工作区身份由 WorkspaceIdProvider(Context)提供,但源码里该 Provider 已删,改为 slug-first 派生(依据:packages/core/hooks.tsx:9 注释)。读架构以源码为准。
  • "离开工作区必须清空"是易漏的手动契约。 setCurrentWorkspace(null, null) 靠人记得调,忘了就残留旧标识(依据:packages/core/auth/store.ts:71)。
  • 工作区 leave 是已知技术债。 删除工作区严格"先等服务器再清理",但 leave 当前是先清理/跳转再发 mutation,只为规避 member:removed 实时竞态;根 CLAUDE.md "Desktop Rules" 明确说这是债、不是可复用范式。
  • 本章不覆盖的: 两套 WebSocket 通道与多实例扇出见 04-realtime-protocol;服务端任务状态机见 03-task-dispatch-lifecycle;15+ 种编码 CLI 如何抽象成一种执行见 01-agent-runtime

9. 代码地图(导航索引)

主题文件路径符号名
workspace 分层与 catalogpnpm-workspace.yamlpackages / catalog
边界与状态规则(权威声明)CLAUDE.md"Package Boundaries" / "State Rules"
views 依赖(无 next/router)packages/views/package.jsondependencies
core 依赖(无 react-dom)packages/core/package.jsondependencies / peerDependencies
跨层引用护栏packages/eslint-config/base.jsimport-x/no-extraneous-dependencies
桌面导航黑名单apps/desktop/eslint.config.mjsno-restricted-imports / no-restricted-syntax
服务态 query key(带 wsId)packages/core/issues/queries.tsissueKeys
wsId 推导(slug-first)packages/core/hooks.tsxuseWorkspaceId
当前工作区派生packages/core/paths/hooks.tsxuseCurrentWorkspace / WorkspaceSlugProvider
客户态 store(过滤/排序)packages/core/issues/stores/view-store.tsIssueViewState / useIssueViewStore
乐观更新三段式packages/core/issues/mutations.tsuseUpdateIssue
pending-message 模式packages/views/chat/components/use-chat-controller.tsoptimistic / enqueueLocalRestore
WS→Query cachepackages/core/issues/ws-updaters.tsonIssueCreated / onIssueUpdated
导航抽象接口packages/views/navigation/types.tsNavigationAdapter
平台无关链接组件packages/views/navigation/app-link.tsxAppLink
web 导航落地apps/web/platform/navigation.tsxWebNavigationProvider
desktop 导航落地apps/desktop/src/renderer/src/platform/navigation.tsxDesktopNavigationProvider / tryRouteToOtherWorkspace
工作区标识镜像packages/core/platform/workspace-storage.tssetCurrentWorkspace
API 漂移防御packages/core/api/schema.tsparseWithFallback
schema 真实调用点packages/core/api/client.tsgetMe
mobile 独立性(仅借类型)apps/mobile/package.json / apps/mobile/CLAUDE.md@multica/core (import type)
CLI 客户端(第四端)server/cmd/multica/cmd_issue.go / cmd_chat.go