跳到主要内容

前端:单一 React UI 与实时会话渲染

30 秒导读: OpenWork 只有一个 React 19 + Vite 前端。桌面 Electron 外壳加载它、 普通浏览器直接跑它、云端也是同一份。它的核心工作是:订阅后端一条 SSE 事件流,把一个 编码 agent(OpenCode)边想边做的过程——文字、工具调用、待办计划、要不要放行某个危险 操作——增量地、一帧一帧地渲染成一个连不懂代码的人也能看懂并能审批的控制面。

本章是 OpenWork 系列的第五章。它只讲前端这一层。相邻章节请看: 桌面外壳与启动流程主机运行时 orchestratoropenwork-server 鉴权代理可扩展性远程与云。本章会引用它们但不重复其内容。


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

一句话定义: 这是 OpenWork 唯一的用户界面——一个用 React 19 写的单页应用,负责把后台 那个「会自己读文件、跑命令、改代码」的 AI agent 的一举一动,实时画到屏幕上。

它要解决的问题: 一个自主 agent 在后台干活,过程是一串机器事件(「我开始生成第 3 个字」 「我要执行 rm -rf」「我更新了待办清单」)。非技术用户看不懂这些事件。前端的职责就是当 一个「翻译 + 值班台」:把事件翻译成人话(「正在读 config.ts」),把危险动作拦下来问一句 「要放行吗?」,并且这一切要边发生边显示,不能等 agent 干完再刷新。

同一份代码,三种部署。 这是 OpenWork 前端最关键的一个约定——桌面、web、云跑的是同一个 React 应用,区别只在它「连到哪个后端」:

部署形态谁加载这份 UIUI 连的后端
桌面Electron 外壳(见第 1 章)本机 openwork-server 挂载的 /opencode
web普通浏览器同源的 OpenCode 反向代理
浏览器托管 worker(见第 6 章)

它靠一个「运行时探测叶子模块」判断自己身在何处:

// app/lib/runtime-env.ts:4 —— 真实源码(叶子模块,故意不 import 任何东西)
export function isElectronRuntime() {
return typeof window !== "undefined" && (window as Window).__OPENWORK_ELECTRON__ != null;
}

桌面外壳启动时往 window 上挂一个 __OPENWORK_ELECTRON__ 标记,前端据此切换「用系统级 desktopFetch 还是普通 fetch」等行为。一份 UI 之所以能三处通吃,靠的就是把「我在哪」 收敛成这一个探测点,而不是编译三份。

一句话直觉: 把前端想成体育比赛的实时字幕组。后台是赛场(agent 在打),后端发来的 是一条源源不断的赛况电报(SSE 流),前端要在观众(用户)面前把电报逐字变成看得懂的 解说、比分板(待办进度)和「是否允许换人」的裁判提示框。


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

2.1 分层不变量:两层 + 一条铁律

前端源码分成两大层,分界线是**「能不能 import React」**:

apps/app/src/
├── app/ 框架无关层 —— 绝不 import React(强制不变量)
│ ├── lib/ 客户端桥:opencode / openwork-server / den / desktop(IPC)
│ ├── runtime-env.ts desktop-types.ts den-types.ts ← 叶子模块(import 别人 = 0)
│ └── cloud/ session/ utils/ … ← 框架无关的功能助手
├── i18n/ 翻译 + t();谁都不从 app/ 里 import
└── react-app/ React 世界
├── shell/ 启动、provider 组合、路由、命令面板(最上层,可 import 一切)
├── kernel/ 全局状态 + provider 栈(server→sdk→sync→local)、zustand store
├── infra/ 纯 React 运行时设施(query-client、共享查询缓存)
└── domains/ 按产品域切分:session/ workspace/ settings/ connections/ cloud/ …

分层规则由 madge --circular 在 CI 里强制零环依赖(见 react-app/ARCHITECTURE.md)。 读者只需记住四条方向:

规则白话
app/i18n/ 不 import react-app/框架无关层永远在下,不知道 React 存在
叶子模块(runtime-env/desktop-types/den-types)import 数 = 0低层客户端能安全复用它们,不会被拖进 i18n
kernel/infra/domains/ 之下基础设施不许 import 具体产品域
shell/ 在最顶只有它能 import 任何东西

为什么这么严? 因为「框架无关层」里那些客户端(opencode.tsopenwork-server.ts) 既要给 React 用,也要给桌面主进程、给测试脚本用。一旦它们不小心 import 了 React 或 i18n, 就没法在非浏览器环境跑了。叶子模块「零 import」正是这道防线的地基。

2.2 Provider 栈:一层套一层的运行时

React 世界的运行时是一串从外到内嵌套的 Provider,每层负责一件事,内层依赖外层已备好的 能力(源自 react-app/ARCHITECTURE.md 的 Data flow 图):

QueryClientProvider + PlatformProvider React 入口(index.react.tsx)
└─ ServerProvider 选定连哪个后端 URL、轮询健康
└─ GlobalSDKProvider 基于 URL 造出 OpenCode / openwork-server 客户端
└─ GlobalSyncProvider 打开 SSE 事件流,把事件灌进查询缓存
└─ LocalProvider 本地偏好 / 会话恢复记忆
└─ app-root → 路由
├─ session-route → domains/session(聊天主界面)
├─ settings-route → domains/settings、connections
└─ workspace / cloud / onboarding 流程

ServerProvider 是「一份 UI 三处通吃」在代码里落地的地方。它决定当前连哪个 baseUrl,并每 10 秒轮询一次健康:

// react-app/kernel/server-provider.tsx:107 —— 托管 web 部署强制走代理,忽略本地残留 URL
const forceProxy =
!isDesktopRuntime() &&
isWebDeployment() &&
(import.meta.env.PROD ||);

桌面则相反:它只认 openwork-server 挂出来的 /opencode URL,并主动忽略旧的裸 OpenCode 端口 (server-provider.tsx:143,因为那些临时端口重启后就失效,否则会永远刷「连接被拒」的噪音)。

2.3 一条主线走一遍(高层)

一次「用户发消息 → 看到 agent 回复」在前端大致这样流动:

用户在 composer 敲字回车
└─> 客户端桥 POST 到 openwork-server → OpenCode 开始跑
后端 OpenCode 一边跑一边通过 SSE `/event` 往回吐事件
└─> GlobalSyncProvider 的一条订阅循环收下每个事件
└─> applyEvent() 按事件类型分派
├─ message.part.delta → 攒进缓冲,每帧刷一次(打字机效果)
├─ message.part.updated → 声明一个工具调用 part
├─ todo.updated → 更新执行计划时间线
├─ permission.asked → 弹出「放行吗」审批框
└─ session.idle/error → 收尾、埋点
└─> 事件写进 TanStack Query 缓存(而非直接改组件)
└─> 订阅这些缓存 key 的组件自动重渲染 → 屏幕更新

关键设计:事件不直接操作组件,而是写进查询缓存;组件订阅缓存 key 做「哑」渲染。下一节 拆解这条流水线里几处最巧的地方。


3. 核心原理:实时会话渲染流水线

这是整章工程含量最高的部分。素材集中在 react-app/domains/session/sync/session-sync.ts (约 1200 行,是这条流水线的心脏)以及 src/lib/ 下几个纯函数解析器。

3.1 一条 SSE 订阅,自带重连与看门狗

要解决的小问题: agent 干活可能持续几分钟,网络会断、服务会重启。前端得始终挂着一条 到后端的事件流,断了要自己重连,卡死了要自己重启。

思路: 用 OpenCode SDK 的 event.subscribe() 拿到一个异步可迭代的事件流,for await 逐个 消费;外面套指数退避重连,再加一个「看门狗」定时器防止「连接还在但一直没数据」的假死。

// session-sync.ts:1024 connect() —— 真实源码,精简后
const sub = await client.event.subscribe(undefined, { signal: connectionController.signal });
retryDelayMs = 1_000; // 连上就把退避重置
for await (const raw of sub.stream) {
lastEventAt = Date.now(); // 每收一个事件盖一次时间戳
const event = normalizeEvent(raw);
if (event) applyEvent(entry, input.workspaceId, event);
}

三重容错互相配合:

机制位置作用
指数退避重连scheduleRetry session-sync.ts:1014断线后 1s→2s→…→最多 10s 重试
看门狗session-sync.ts:1053每 10s 检查;超过 staleStreamMs(30s)没事件就主动 abort 重连
引用计数复用ensureWorkspaceSessionSync session-sync.ts:1071同一 workspace 多处订阅只开一条流,最后一个撤走才真正关闭

巧妙处: 看门狗解决的是「TCP 连接没断、但服务端悄悄不发了」这种最难察觉的僵死——只靠 for await 永远不会醒。用「最后事件时间戳 + 定时体检」把它变成可恢复的常态。

3.2 打字机效果:把每个 token 事件攒成「一帧一提交」

要解决的小问题: 模型逐字生成,后端每个 token 都发一个 message.part.delta 事件。若每个 事件都立刻改一次缓存,就会每个 token 触发一次全量重渲染——长回复时主线程被打爆,用户看到 的是「打了两个字就卡死」。

作者在类型定义里直接把这个坑写成了注释:

// session-sync.ts:48 —— SyncEntry 字段注释(真实源码)
// Coalesce rapid-fire delta events from the SSE stream into one cache
// commit per animation frame. Without this, a long response produces a
// setQueryData per token; each triggers a full transcript re-render
// (~27ms on large sessions) which starves the main thread and looks to
// the user like the app "freezes after 2 words."

思路: delta 事件不立刻写缓存,而是先塞进 deltaFlushBuffer,用 requestAnimationFrame 攒到下一帧再一次性合并提交。流程如下:

delta 事件 ──push──> deltaFlushBuffer ──scheduleDeltaFlush(每帧一次)──┐

coalescePendingDeltas: 同一 part 的多段 delta 先在内存里拼成一段

flushDeltas: 按 session 分组,每个 transcript 缓存本帧只 setQueryData 一次

appendDelta: 只克隆目标那一条消息 + 它的 parts 数组(不是 messages.map 全表)
  • scheduleDeltaFlush(session-sync.ts:907)优先用 requestAnimationFrame;页面不可见时降级 为 setTimeout(50ms),没有 window 时用 queueMicrotask——覆盖桌面/web/测试三种环境。
  • coalescePendingDeltas(session-sync.ts:570)把同一 (session,message,part) 的多个 delta 先拼接,减少后续工作量。
  • appendDelta(session-sync.ts:499)是性能关键:老实现每个 token 都 messages.map + parts.map,是 O(N×P);新实现用 findIndex 只克隆命中的那一条,把每 token 的分配从「上千个 对象」降到常数级。

这段值得带走: 「高频事件 → 按帧批处理 → 最小化不可变更新的克隆范围」是所有实时流式 UI 的通用三板斧,这里三步齐全。

3.3 文本 vs 推理:不信事件的 field,只信 part 的 type

一个真实的坑。 OpenCode 对「正文文本」和「推理(reasoning)过程」两种流,都发 field: "text" 的 delta 事件——没法从事件本身区分。二者真正的区别只在 message.part.updated 事件里那个 part 的 type 上。

于是流水线这样处理这个时序竞争:

  • delta 到达时一律先记成待定,不猜类型(session-sync.ts:876reasoning 硬编码为 false,并配长注释说明「不信 props.field」)。
  • 若这个 part 还没被 message.part.updated 声明过,delta 就存进 entry.pendingDeltas 挂起 (session-sync.ts:982),等 part 类型揭晓后再按正确的种类落位。
  • message.part.updated 到来、类型明确后,用它作为唯一真相,并把之前挂起的文本 「取较长者」而非拼接(session-sync.ts:821,因为二者都是同一条流的累积视图,拼接会双计 字节,导致推理文本在 UI 里重复)。

巧妙处: 这是「事件乱序 + 字段不可信」下的经典防御——用后到但权威的信号(part.type)去 校正先到但含糊的信号(delta),并对「累积 vs 增量」两种语义用「取长」而非「相加」化解重复。

3.4 stub 角色推断:别让用户消息闪成 agent 样式

当 part 事件抢在 message.updated 之前到达时,前端得先造一个「消息壳」给 part 安家。壳的 角色若写死成 "assistant",那用户自己的消息会先闪一下 agent 的气泡样式再纠正——很刺眼。

解法是从对话交替规律推断(inferStubRole session-sync.ts:476):聊天是「用户/agent」轮流, 新消息几乎总是最后一条已知消息的反角色;transcript 为空时第一条必是用户。这样在真正的 角色事件到来前,壳就已经大概率对了。

3.5 快照 + 直播:两个来源合并成一份 transcript

会话有两个数据来源:历史快照(打开会话时一次性拉取的完整消息)和直播事件流(此刻正在 发生的)。二者要拼成一份连贯的 transcript。

  • 快照经 snapshotToUIMessages(usechat-adapter.ts:188)转成统一的 UIMessage[]
  • 合并由 mergeSnapshotAndLiveMessages(message-merge.ts:97)负责:同 id 的消息按位置合并 parts,并对文本「取较长者」(直播缓存里可能有比快照更新的流式文本),最后按 created 时间戳 重排。
  • 失败的一轮会被转成一条合成错误消息(createSessionErrorUIMessage usechat-adapter.ts:99), 并且用出错那一轮的 assistant message id 作为 key——这样直播的 session.error 事件和之后 重载快照会收敛到同一条错误消息而非重复两条(usechat-adapter.ts:240session-sync.ts:640 两处用同一套 key 规则)。

4. 把 agent 活动渲染成人话

上一节把事件变成了结构化的 UIMessage[]。这一节讲「结构 → 人能读懂的控制面」。这些转换大多是 src/lib/ 下的纯函数,单元测试友好、与 React 解耦。

4.1 工具活动标签:edit(filePath=…) → 「正在编辑 config.ts」

模型的工具调用是结构化的(工具名 + 参数),直接展示对非技术用户毫无意义。 getToolActivityLabel(lib/tool-activity.ts:50)把每种内建工具翻译成一句人话:

// lib/tool-activity.ts:55 —— 真实源码节选
if (isReadToolPart(part)) return `Reading ${parseFilename(part.input?.filePath)}`;
if (isEditToolPart(part)) return `Editing ${parseFilename(part.input?.filePath)}`;
if (isWebSearchToolPart(part)) {
const query = part.input?.query?.trim();
return query ? `Searching the web for ${truncateText(query, 44)}` : "Searching the web";
}

两个细节值得注意:

  • 对流式的半成品输入安全。 参数是边流边到的,filePath 可能还没来,所以处处用 ?. 和 兜底文案(函数头注释明说「Safe against partial streamed input」lib/tool-activity.ts:46)。
  • getActiveToolLabel(lib/tool-activity.ts:113)从后往前找最近一个仍在飞行中的工具, 给状态栏显示「此刻 agent 在干嘛」。判定用 isToolPartInFlight(状态为 input-streaminginput-available)。这些标签消费于 components/ui/tool.tsxcomponents/chat/message-list.tsx

4.2 待办 → 执行计划时间线

要解决的小问题: agent 用一个 TodoWrite 工具维护自己的待办清单。把它渲染成带进度的 时间线,用户就能像看快递轨迹一样看 agent「计划做几步、走到第几步」。

数据通路很短:后端发 todo.updated 事件 → 写进 todoKey 缓存(session-sync.ts:675)→ use-session-interactions.tsuseQueryCacheState 订阅该 key 拿到 todosTodoPanel(surface/session-surface.tsx:229)渲染。

TodoPanel 把三种状态映射成不同的视觉:

todo 状态视觉代码
completed绿圈 + 对勾session-surface.tsx:253
in_progress琥珀色圆点(进行中)session-surface.tsx:255
cancelled灰色 + 删除线session-surface.tsx:254
其它(pending)空心灰圈兜底

折叠态标题直接显示进度 已完成/总数(session-surface.tsx:234),一眼看到「3/7」。

4.3 权限审批:allow once / always / deny

这是把「自主 agent」变「可信 agent」的关键一环。 agent 要跑 bash、改文件、访问外部目录 前,后端会发一个权限请求事件,前端拦住、弹框让用户决定,agent 在此期间挂起等待。

事件侧支持新旧两套协议,归一化后写进同一个 permissionKey 缓存:

permission.asked (旧版 legacy) ─┐
permission.v2.asked (新版 v2) ─┼─> permissionWithReceivedAt 归一化
│ ─> permissionKey 缓存(按到达时间排序)
permission.replied / v2.replied ─────┘ ─> 回复后从缓存移除

(处理器在 session-sync.ts:683700717;归一化函数 v2PermissionWithReceivedAt session-sync.ts:229 把 v2 的 action/resources/save 字段映射到统一形状。)

审批 UI 给三个按钮,语义清晰:

按钮reply 值含义
Deny"reject"拒绝这次操作
Allow once"once"只放行这一次
Allow for session"always"本会话内同类操作都放行

(按钮见 permission-approval-modal.tsx:344/351/359。)弹框还会把命令、cwd、diff、目标文件 等风险信息摊开给用户看(metadataDetailKeys permission-approval-modal.tsx:41),让「放行」 是知情决定而非盲点。回复走 respondPermission(use-session-interactions.ts:120):v2 请求用 client.v2.session.permission.reply,旧版用 client.permission.reply,回复成功后乐观地从缓存 里摘掉这条待审(use-session-interactions.ts:144)。

4.4 产物(artifacts):从消息里「挖」出用户产出的文件

要解决的小问题: agent 干活会产出文件(写了一份 markdown、导出一个表格)。用户想要一个「我 拿到了哪些成果」的清单,而不用去翻聊天记录。

getArtifactsFromMessages(lib/artifacts.ts:310)扫描整段对话,从三种地方提取文件:

  1. 工具调用:Write/EditfilePathApplyPatch*** Add File: 等补丁头 (parseApplyPatchPaths lib/artifacts.ts:201)。
  2. 助手正文里的自然语言提及:getArtifactPathsFromText(lib/artifacts.ts:226)先用一个 「像在说产出」的关键词正则(created/saved/exported/generated…)过滤,再用文件名正则抠出路径 ——避免把随口提到的路径都当成产物。
  3. source-document part:模型显式附带的文件。

抠到的路径经 getArtifactType(lib/artifacts.ts:64,按扩展名分成 markdown/sheet/slides/ image/pdf… 等类型)归类,去重后按「更新时间→消息位置」排序。UI 侧 useArtifacts hook (lib/artifacts.ts:342)把它接进 React,点击可预览(消费于 components/chat/artifact.tsx)。

4.5 网页搜索结果:把纯文本工具输出解析成卡片

web 搜索工具的输出是一坨纯文本(Title:/URL:/Highlights: 分块)。 parseWebSearchResults(lib/websearch-results.ts:11)用几个正则把它切成结构化的 {title, url, description}[](标题缺失时回退成 hostname),供 components/tools/websearch.tsx 渲染成可点的结果卡片。这类「后端给纯文本、前端解析成 UI」的小解析器,是保持前后端松耦合的 常见手法。


5. 语音控制(domains/session/voice)

语音是一条独立的旁路,让用户用说的来驱动 OpenWork,而非打字。

架构: 前端和 OpenAI Realtime 建一条 WebRTC 连接(voice-panel.tsx:582https://api.openai.com/v1/realtime/calls 发 SDP;连接对象存在模块级的 voiceRealtime voice-panel.tsx:69,含 peer/channel/stream)。麦克风音频实时上行,模型语音下行播放,状态机在 idle→connecting→listening→speaking 间流转。

巧妙处 —— 语音靠「控制面」而非直接操作 UI 干活。 模型不直接碰界面,而是通过三个 function-call 工具间接操作,由 executeOpenWorkTool(voice-panel.tsx:261)桥接到挂在 window 上的 __openworkControl 控制面:

语音工具干什么
openwork_snapshot读当前 OpenWork 状态(control.snapshot())
openwork_list_actions列出可执行的动作
openwork_execute_actionactionId 执行某个动作

这层间接的意义:语音、UI 按钮、命令面板共用同一套 action 定义,新增一个能力只需注册一个 action,三种入口(点、说、搜)自动都能用。


6. 一个关键约定:工作区/会话是「路由态」,不是全局态

这是本前端最容易踩、也最需要点明的设计约定(源自 react-app/ARCHITECTURE.md 的 "Active workspace and session" 一节)。

「当前是哪个工作区、哪个会话」不存在某个全局可变变量里,而是从 URL 读。规范路由形如:

/workspace/:workspaceId/session/:sessionId
/workspace/:workspaceId/settings/:tab

规则要点:

  • 在会话/设置路由里,当前 workspace 先从 URL 的 workspaceId;当前 session 从 sessionId 读。一个被选中的 session 绝不能暗示一个和 URL 不同的 workspace。
  • 旧的 openwork.react.activeWorkspace 等 localStorage 值只是恢复/兜底记忆,在 workspace-scoped URL 生效时不是权威
  • URL 里的资源找不到时,显示 not-found 让用户从侧栏选,绝不静默回退到「第一个工作区」。
  • 拼这些路径要用 shell/workspace-routes.ts,不许手搓 /session/...

为什么这样设计? 把「你在看哪个会话」放进 URL,就自动获得了浏览器前进/后退、可分享链接、 刷新不丢上下文、多标签各看各的——这些全局可变状态给不了的能力。这是把「导航状态归 URL、 瞬态 UI 状态归组件、跨域共享状态才进 store」这条现代前端分工落到实处。

状态的三处归属:

状态种类归属例子
导航态(在哪)URL 路由当前 workspace / session
服务端缓存态TanStack Query(infra/query-client.ts)transcript、todos、权限、快照
全局应用态Zustand(kernel/store.ts)跨域共享的少量状态
域内瞬态该域内部domains/session/sync/domains/settings/state/

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

  • 一份 UI 三处通吃,靠单点运行时探测。 isElectronRuntime(app/lib/runtime-env.ts:4)+ ServerProvider 里的 forceProxy(server-provider.tsx:107)把「我在哪、连哪」收敛成少数 几个判断,而不是编译三份产物。
  • 按帧批处理流式事件。 delta 攒进缓冲、requestAnimationFrame 每帧提交一次 (scheduleDeltaFlush session-sync.ts:907),把「每 token 一次全量重渲染」降为每帧一次。
  • 不可变更新只克隆命中路径。 appendDelta(session-sync.ts:499)用 findIndex 避开 messages.map,把每 token 的对象分配从 O(N×P) 降到常数。
  • 用权威信号校正含糊信号。 delta 的 field 不可信,一律以 message.part.updatedpart.type 为准,挂起未定类型的 delta(session-sync.ts:876/982);累积文本「取长」不「相加」 以防双计(session-sync.ts:821)。
  • SSE 假死看门狗。 只靠 for await 无法发现「连着但不发数据」,靠最后事件时间戳 + 定时体检 主动重连(session-sync.ts:1053)。
  • 错误按「轮」归 key。 直播事件与快照重载用同一套 turnKey,让同一次失败收敛成一条消息而非 重复(usechat-adapter.ts:99/240)。
  • 纯函数解析器与 React 解耦。 工具标签、产物提取、搜索解析都是 src/lib/ 里的纯函数,可单测、 可复用。
  • 一套 action 三种入口。 点击、语音、命令面板共用 __openworkControl 的 action 注册 (voice-panel.tsx:261)。

8. 边界与局限(诚实)

  • 前端不是真相源。 transcript 由「快照 + 直播」合并推导;若两者都失败(如附件用了模型读不懂的 媒体类型),同一个 provider 错误会在每次后续 prompt 重放,前端只能给出「回退到附件之前或新开 会话」的提示(usechat-adapter.ts:52),自身无法修复服务端历史。
  • 打字机批处理有一帧延迟。 页面不可见时降级为 50ms 定时(session-sync.ts:922),不是零延迟。
  • 待办 UI 只反映 agent 自报的 TodoWrite agent 不用这个工具就没有时间线;它是 agent 的 自述,不是对真实进度的独立校验。
  • 产物提取靠启发式。 getArtifactPathsFromText(lib/artifacts.ts:226)从助手正文里用正则 猜文件路径,可能漏(没被关键词命中)或多(把非产物路径当成产物)。工具调用来源(Write/Edit) 才是可靠的。
  • 语音依赖外部 Realtime 服务且需要麦克风权限(macOS 会先探测系统授权 voice-panel.tsx:255); 离线或未授权即不可用。
  • 本章不覆盖 design-system 组件。可复用的展示原语在 react-app/design-system/,本章聚焦 agent 相关面与分层设计,不逐个讲组件。

9. 横向对比

同为「实时把 agent 过程渲染给人看」,不同项目取舍不同。OpenWork 的特点:

  • 面向非技术用户的控制面,而非给工程师看的原始日志——所以有工具活动人话标签、待办时间线、 产物清单、语音这些「翻译层」,以及权限审批这种「值班台」。
  • 单一 React 应用三处通吃,把部署差异收敛成运行时探测(对比:很多工具为桌面/web 各写一套)。
  • 导航态归 URL,换来可分享/可后退/多标签隔离。

更底层的服务侧(SSE 从哪来、鉴权怎么做)见第 3 章 openwork-server; 运行时如何监管 OpenCode sidecar 见第 2 章 orchestrator


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

主题文件路径关键符号
分层不变量 / 依赖规则apps/app/src/react-app/ARCHITECTURE.md(文档)
运行时探测(叶子)apps/app/src/app/lib/runtime-env.tsisElectronRuntime isDesktopRuntime
后端 URL 选择 / 健康轮询apps/app/src/react-app/kernel/server-provider.tsxServerProvider checkHealth forceProxy
SSE 订阅 / 重连 / 看门狗apps/app/src/react-app/domains/session/sync/session-sync.tsstartSync connect scheduleRetry
事件分派同上applyEvent
delta 按帧批处理同上scheduleDeltaFlush flushDeltas coalescePendingDeltas appendDelta
文本/推理消歧、stub 角色同上inferStubRole pendingDeltas(message.part.updated 处理)
权限事件归一化同上permissionWithReceivedAt v2PermissionWithReceivedAt
待办事件同上todo.updated 处理器
快照→UIMessage / 错误消息apps/app/src/react-app/domains/session/sync/usechat-adapter.tssnapshotToUIMessages createSessionErrorUIMessage describeOpencodeSessionError
快照+直播合并apps/app/src/react-app/domains/session/sync/message-merge.tsmergeSnapshotAndLiveMessages
工具 part 解析apps/app/src/react-app/domains/session/sync/parse-tool-parts.tsparseDynamicToolUIPart shouldDeferInProgressTool
工具活动人话标签apps/app/src/lib/tool-activity.tsgetToolActivityLabel getActiveToolLabel
待办时间线 UIapps/app/src/react-app/domains/session/surface/session-surface.tsxTodoPanel
权限审批 UIapps/app/src/react-app/domains/session/chat/permission-approval-modal.tsxPermissionApprovalModal PermissionApprovalPanel
权限回复动作apps/app/src/react-app/domains/session/sync/use-session-interactions.tsrespondPermission respondQuestion
产物提取apps/app/src/lib/artifacts.tsgetArtifactsFromMessages getArtifactPathsFromText useArtifacts
网页搜索解析apps/app/src/lib/websearch-results.tsparseWebSearchResults
语音控制apps/app/src/react-app/domains/session/voice/voice-panel.tsxexecuteOpenWorkTool voiceRealtime
客户端桥apps/app/src/app/lib/opencode.ts openwork-server.ts den.ts desktop.ts