14 · Web 控制面
React、TanStack Router、Effect Atom、Zustand 与 Ghostty 终端如何围绕共享运行时协作。
Web 是主要控制面,不是状态权威
apps/web 使用 React 19、TanStack Router、Effect Atom、Zustand、Lexical 和 Ghostty/WASM 终端。组件数量与体积都很大,
但架构上始终要守住一条线:Server 投影决定 domain truth,Web store 负责同步缓存、派生与交互状态。
启动与路由模式
main.tsx 根据宿主选择 history:
- 普通浏览器使用 browser history;
- Electron renderer 使用 hash history,避免自定义 scheme/本地文件刷新时路由交给错误的 HTTP server。
router.ts 与生成的 route tree 组合页面。
核心 URL 显式包含 environmentId 和 threadId,例如 _chat.$environmentId.$threadId.tsx,这让深链可以定位远端环境,
而不是默认“当前连接只有一个”。
Provider 树与共享运行时
Web 启动大致组合:主题/错误边界 → Clerk(云访问时)→ Effect runtime/Atom registry → connection runtime → Router。
apps/web/src/connection/runtime.ts 将浏览器存储、WebSocket/HTTP、online 事件等 platform layer 注入 client-runtime。
Effect Atom 适合和 Effect query/runtime 组合的远端事实;Zustand/React state 更适合面板宽度、临时选择、未提交 composer 等局部交互。 选择多种状态工具不是问题,问题在于同一个事实是否有两个所有者。
Thread shell 与 detail
侧边栏需要大量 thread 的轻量字段,聊天页只需要当前少数 thread 的完整消息/活动。把所有 detail 塞进全局列表会让启动和每次事件都很重。
T3 Code 区分:
- thread shell:标题、状态、project、排序/置顶/稍后等列表字段;
- thread detail:messages、activities、turns、approvals、checkpoint 等深内容。
client-runtime/state/shell.ts 与 threadDetail.ts 分别装载和 reduce;Web 路由按需获取 detail,并做 retention,
离开页面后不必永远把所有历史留在内存。
ChatView 为何巨大
聊天视图同时处理:
- 交错的 user/assistant messages 与 activities;
- streaming segment 和自动滚动锚点;
- approval/user input;
- proposed plan 与 implement action;
- composer、附件、路径搜索、模型和权限模式;
- subagent/collaboration 展示;
- checkpoint/diff/revert;
- terminal/preview/right panel;
- mobile/desktop 响应式差异。
巨型组件是维护风险,但它也揭示了 UI 的真实耦合:滚动、流状态、composer 高度和活动展开会相互影响。合理重构应沿稳定状态所有权切片, 不能只是把 JSX 搬进 30 个文件后仍共享一堆隐式 refs。
流式渲染与稳定身份
Projector/ingestion 已合并 token,Web 仍要避免每个 delta 重排整棵消息树。消息、segment、activity 使用稳定 ID;组件按 ID 订阅 atom 派生, 列表使用稳定 key。滚动策略要区分:
- 用户仍在底部:新 delta 自动跟随;
- 用户向上阅读:保留锚点并显示“跳到最新”;
- 图片/代码块异步改变高度:基于锚点修正,而非每次
scrollTo(bottom)。
这些属于展示逻辑,不应反向修改 Server 的 message lifecycle。
Lexical Composer
Lexical 支持富文本输入、路径 mention/搜索、附件与结构化内容。发送前必须转换成 contracts 接受的稳定 payload, 不能把编辑器内部 node JSON 当长期协议。草稿是客户端状态;成功接受后再清空,网络失败时保留并用同一 command ID 重试。
浏览器内终端
Web 使用自定义 Ghostty terminal WASM surface,而 PTY 在 Server。典型路径:
keyboard → terminal input RPC → Server PTY → byte stream → client buffer → WASM render终端流与 orchestration event stream 分开,因为字节吞吐、背压和重放语义不同。客户端维护 buffer replay,连接恢复时请求合适基线, 不把每个 terminal byte 写进领域事件日志。
Preview 与桌面增强
普通 Web 可以显示可达 preview URL;Electron renderer 还能通过 IPC 使用 webview、元素拾取、画中画和浏览器自动化宿主。
Web 组件通过 capability/platform adapter 检测能力,不应直接假设 window.electron 始终存在。
这种模式允许同一 UI bundle 在浏览器与桌面运行,同时把高权限操作限制在 preload 暴露的窄 IPC。
Cloud auth 与本地 auth
云连接使用 Clerk 辅助身份流程,最终执行环境仍有自己的授权/session/ticket。authBootstrap、connect/pair routes 和 connection runtime
协调两层,UI 需要区分“未登录 T3 Connect”和“已登录但目标环境未配对”。
性能风险清单
- Sidebar 数千 thread 的排序/分组应 memoize 并使用 shell pagination;
- Chat detail 不应被任何无关 thread shell event 整体重算;
- stream reducer 保持结构共享,避免复制整个环境 state;
- 终端/preview 高频事件与 React domain state 隔离;
- 大 diff、markdown 高亮和搜索可按需加载;
- 路由离开后释放 detail subscription/retention lease。
源码锚点
可迁移经验
- URL 显式携带 environment identity,多后端产品才有可靠深链。
- 列表 shell 与当前对象 detail 分层,是大规模实时 UI 的基本模型。
- 服务端事实与局部交互状态可使用不同工具,但所有权必须唯一。
- 高频终端/预览流不要强行进入通用 React/领域事件管线。
- 跨浏览器/Electron 复用 UI 时,通过 capability adapter 暴露平台能力,而非到处读全局变量。