数据截至 (上游 commit 877a71568f6d)
多端前端:共享包分层、服务态 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/web | Next.js 网页壳 | web |
apps/desktop | Electron + react-router 桌面壳 | desktop |
apps/mobile | Expo / React Native 手机壳 | mobile(独立) |
server/cmd/multica | Go 写的命令行客户端 | CLI(第四种客户端) |
一句话直觉: 把它想成一家餐厅。core 是后厨(菜怎么做),ui 是餐具(盘子刀叉),views 是摆好的成品菜;apps/web、apps/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-dom、localStorage、process.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 包,没有任何 next 或 react-router(依据:packages/views/package.json 的 dependencies)。packages/core 的 dependencies 里只有 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 Query | Zustand |
| 装什么 | 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-dom 引 useNavigate / Navigate,也禁止直接调 router.navigate——因为桌面的导航必须走"标签协调器"(Coordinator)协议(依据:apps/desktop/eslint.config.mjs:55 起的 no-restricted-imports 与 no-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(原子组件不许有业务)
关键细节: ui 与 core 必须互相独立——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:33 起 issueKeys)。
// 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 里全是 statusFilters、priorityFilters、grouping、sortBy 这类纯前端选择(依据:packages/core/issues/stores/view-store.ts:181 IssueViewState;store 在 :584 useIssueViewStore 用 create() + persist 中间件建立)。
巧妙的持久化取舍: 不是所有客户态都值得持久化。agentRunningFilter(只看"当前有 agent 在跑"的 issue)故意不持久化——因为运行状态每秒都在变,持久化会让用户下次回来看到一个空列表却不知道为什么(依据:view-store.ts:171 区块内该字段注释,及 :472 起 persist 的 partialize/merge 配置)。规则:持久耐久偏好/草稿/布局;不持久服务数据和易变 UI 态。
一条容易踩的坑: Zustand selector 必须返回稳定引用。selector 里当场 map/filter 出一个新数组,会让组件每次都重渲染;需要派生就配 useShallow 或浅比较(依据:根 CLAUDE.md "State Rules" 明列此条)。
3.4 乐观更新:四条同时成立才能做
要解决的小问题: 点一下"标记完成",要不要等服务器回来再变 UI?等,则卡顿;不等,则要处理失败回滚。
思路: Multica 不无脑乐观。它给出四条必须同时成立的门槛,缺一条就老实等服务器(依据:根 CLAUDE.md "State Rules" 的 optimistic 条目):
- 结果本地可预测(你知道点完会变成什么样);
- 用户停在同一屏(不发生跳转);
- 失败很罕见;
- 回滚很容易。
典型合格场景:改状态 / 改指派 / 切换某个布尔字段——**"打补丁 → 失败回滚 → 落定后让不确定的投影失效"**三段式。
真实实现: useUpdateIssue 就是教科书式的三段(依据:packages/core/issues/mutations.ts:108 useUpdateIssue):
onMutate:同步打补丁。它先cancelQueries但故意不 await——保持同步,让 cache 更新和mutate()落在同一 tick,否则 @dnd-kit 会先把拖动的视觉状态复位(依据:mutations.ts:246注释)。快照存进 context 供回滚。onError:用快照rollbackIssueChange回滚,卡片弹回原位(依据:mutations.ts:314)。onSettled:让"可能漂移的投影"失效重取,但自己那张 list/detail 不在这里失效——它已在onSuccess用服务器返回值做过外科手术式补丁,再失效会导致成功的一次移动闪一下(依据:mutations.ts:325onSuccess注释、:358onSettled注释)。
反例边界(何时不能乐观): 创建、删除、离开工作区这类会跳转或需要确认的流程,必须先等服务器再跳转/清理,绝不能乐观地把实体从 cache 里删掉(依据:根 CLAUDE.md "State Rules")。原因:第 2 条(停在同一屏)不成立——一旦跳走,回滚就没有"原位"可弹。
3.5 pending-message 模式:聊天发送不用"静默乐观"
要解决的小问题: 聊天发消息既要"手感即时"(有可见的进行中状态),又不能假装成功(万一失败,用户以为发出去了)。
思路: 用 pending-message 模式,和第 3.4 的乐观更新刻意区分:发送全程有可见的 pending 状态;失败不是静默回滚,而是草稿原样保留、可重试(依据:根 CLAUDE.md "State Rules" 的 chat 条目)。
真实实现: 发送走的是 await-then-render(MUL-5181):composer 把用户文本和附件原地扣住——编辑器锁定、按钮经 submitting 转圈——直到服务器接受这次发送;往返落定前不往任何 cache 写东西、也从不清空草稿(依据:packages/views/chat/components/use-chat-controller.ts:537-542 注释)。发送被接受后,才用服务端响应渲染真正的消息、并把带服务端真实 id 的 pendingTask(status: "queued")种进 cache(use-chat-controller.ts:567-596,内部 upsertChatMessageToCaches + seedAcceptedPendingTask)。请求失败时草稿天然还在(ChatInput 从没清过它),并按结构化 403 的原因码给针对性 toast——权限被撤、运行时未绑定各有一句话,而不是笼统的"发送失败"(use-chat-controller.ts:546-561 的 catch 分支)。
// packages/views/chat/components/use-chat-controller.ts:516 附近 —— 示意结构
// await-then-render:编辑器锁定、按钮转圈;往返落定前不写 cache、不清草稿
try {
result = await api.sendChatMessage(sessionId, content, attachmentIds);
} catch (err) {
toast.error(toastForDispatchReason(dispatchReasonCode(err))); // 草稿未动,可直接重试
return false;
}
upsertChatMessageToCaches(qc, sessionId, sent, { seedIfMissing: true }); // 服务端响应渲染
seedAcceptedPendingTask(qc, sessionId, result); // pendingTask: "queued"
为什么不直接用 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:379 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:17 AppLink)。
<AppLink> 自己不认识路由,它把点击翻译成适配器调用:普通点击 → push(href);按住 cmd/ctrl/shift → 若适配器提供了 openInNewTab 就开新标签,否则放行给浏览器原生行为(依据:app-link.tsx:34–49 的 handleClick)。
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:57 的 adapter)。这是唯一允许出现 next/navigation 的层。
4.3 desktop 的落地:标签协调器,不是路由
桌面端复杂得多,因为它有多标签窗口。它的适配器 push 根本不直接调路由,而是把导航翻译成"操作标签会话",再由协调器(Coordinator)把那个单一 router 对齐到活动标签的 URL(依据:apps/desktop/src/renderer/src/platform/navigation.tsx:169 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:109)。它只镜像标识,不存业务数据;slug 没变时直接短路返回,变了才用 queueMicrotask 通知订阅者并触发本地存储的 rehydrate(依据:packages/core/platform/workspace-storage.ts:34 setCurrentWorkspace)。
离开工作区必须显式清空: 登出、切到无工作区的界面时要调 setCurrentWorkspace(null, null),否则镜像会残留旧工作区(依据:packages/core/auth/store.ts:101 与 apps/desktop/src/renderer/src/App.tsx:234 的调用)。这也呼应了"只有 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:38–55)。
// 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:19–34的类型注释、packages/core/api/schemas.ts:760注释)。 - 下游 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:6–10)。
代码印证:apps/mobile 里对 core 的引用清一色是 import type { ... } from "@multica/core/types"——switch-workspace.tsx:31、my-issues.tsx:27-31、inbox.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.go、cmd_chat.go)是完全独立的一端,直接打后端 HTTP/WS,与前端共享包无代码关系,只共享 API 契约(依据:server/cmd/multica/)。运行时与守护进程细节见 01-agent-runtime 与 02-local-daemon。
7. 巧妙之处(可借鉴的技术)
- 用 package.json 白名单当架构护栏。 不靠自觉、不靠约定,靠"没在
package.json声明就 lint 报错"物理阻断跨层引用——import-x/no-extraneous-dependencies(依据:packages/eslint-config/base.js:19)。这比写规范文档可靠得多。 - 同步的乐观补丁。
onMutate故意不 awaitcancelQueries,把 cache 更新压进和mutate()同一 tick,专门为了不和拖拽库(@dnd-kit)的视觉复位打架(依据:packages/core/issues/mutations.ts:133)。这种"为动画时序而放弃 await"的取舍很少见。 - pending-message ≠ 乐观更新。 同一个团队对"改状态"用静默乐观回滚,对"发消息"用 await-then-render——草稿原地保留、往返落定才渲染,失败按原因码提示——因为两者的"失败罕见度"和"回滚代价"不同(依据:
use-chat-controller.ts:537-542的注释)。区分粒度值得学。 - 宽松 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:101)。 - 工作区 leave 是已知技术债。 删除工作区严格"先等服务器再清理",但 leave 当前是先清理/跳转再发 mutation,只为规避
member:removed实时竞态;根CLAUDE.md"Desktop Rules" 明确说这是债、不是可复用范式。 - 本章不覆盖的: 两套 WebSocket 通道与多实例扇出见 04-realtime-protocol;服务端任务状态机见 03-task-dispatch-lifecycle;15+ 种编码 CLI 如何抽象成一种执行见 01-agent-runtime。
9. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| workspace 分层与 catalog | pnpm-workspace.yaml | packages / catalog |
| 边界与状态规则(权威声明) | CLAUDE.md | "Package Boundaries" / "State Rules" |
| views 依赖(无 next/router) | packages/views/package.json | dependencies |
| core 依赖(无 react-dom) | packages/core/package.json | dependencies / peerDependencies |
| 跨层引用护栏 | packages/eslint-config/base.js | import-x/no-extraneous-dependencies |
| 桌面导航黑名单 | apps/desktop/eslint.config.mjs | no-restricted-imports / no-restricted-syntax |
| 服务态 query key(带 wsId) | packages/core/issues/queries.ts | issueKeys |
| wsId 推导(slug-first) | packages/core/hooks.tsx | useWorkspaceId |
| 当前工作区派生 | packages/core/paths/hooks.tsx | useCurrentWorkspace / WorkspaceSlugProvider |
| 客户态 store(过滤/排序) | packages/core/issues/stores/view-store.ts | IssueViewState / useIssueViewStore |
| 乐观更新三段式 | packages/core/issues/mutations.ts | useUpdateIssue |
| pending-message 模式 | packages/views/chat/components/use-chat-controller.ts | optimistic / enqueueLocalRestore |
| WS→Query cache | packages/core/issues/ws-updaters.ts | onIssueCreated / onIssueUpdated |
| 导航抽象接口 | packages/views/navigation/types.ts | NavigationAdapter |
| 平台无关链接组件 | packages/views/navigation/app-link.tsx | AppLink |
| web 导航落地 | apps/web/platform/navigation.tsx | WebNavigationProvider |
| desktop 导航落地 | apps/desktop/src/renderer/src/platform/navigation.tsx | DesktopNavigationProvider / tryRouteToOtherWorkspace |
| 工作区标识镜像 | packages/core/platform/workspace-storage.ts | setCurrentWorkspace |
| API 漂移防御 | packages/core/api/schema.ts | parseWithFallback |
| schema 真实调用点 | packages/core/api/client.ts | getMe |
| mobile 独立性(仅借类型) | apps/mobile/package.json / apps/mobile/CLAUDE.md | @multica/core (import type) |
| CLI 客户端(第四端) | server/cmd/multica/ | cmd_issue.go / cmd_chat.go |