OpenProgram Docs

Web 状态层:每会话独立 store#

web 前端的状态存放方式是:每个会话一个 Zustand store 实例,真正共享的数据放在唯一的 全局 store。这一页说明每类状态各自存在哪里、这条分界线为什么划在这里,以及换成别的 安排会出什么问题。

不预设前端知识。先介绍必要概念,再是对照真实代码的盘点(引用格式为 文件:行号), 第 6 节给出方案本身。


1. 三个概念#

store 是一个共享的变量盒子。 web UI 用 zustand——一个小库,创建一个对象,里面既放数据 (currentSessionIdcomposerDrafts),也放修改数据的函数(setCurrentConvsetComposerInput)。页面任何位置的组件都能直接读这个盒子里的任何字段,不需要一层层 传 props。主盒子在 web/lib/session-store/index.ts:400

组件订阅一个切片。 组件调用 useSessionStore((s) => s.conversations) 时,只有 conversations 变化才会触发它重新渲染。那个选择器函数就是订阅关系。

store 也可以创建多份。 zustand 并不要求只有一个全局盒子。createStore 可以按会话 调用,再用 React context 把属于各自子树的实例递下去。第 6 节定的就是这个形状。

同一个组件可以被渲染多份。 React 组件是模板。<Composer /> 在普通布局里出现一次, 在分屏里出现两次。两份跑同样的代码,因此两份从同一个盒子读同一个字段。如果那个字段是 单个全局值,两份就会互相抢。第 5 节的大部分问题都是这一句话。


2. 主 store 逐字段盘点#

web/lib/session-store/index.tsConvState 接口 (web/lib/session-store/index.ts:46)里声明形状,在 web/lib/session-store/index.ts:400 给初值。下面把每个字段归入三类之一。标注已删除 的字段列在这里,是因为它们说明了这套设计排除掉什么;代码里已经没有它们了。

A 类 —— 按会话隔离#

这些字段是 Record<sessionId, T> 形态的表。同屏两个会话不会冲突,因为各读各的键。 全局 store 收敛到的就是这个形状。

字段 声明位置 内容
conversations web/lib/session-store/index.ts:68 侧栏每会话摘要(虽然按键存,但它是一份列表,见 C 类)
messagesById web/lib/session-store/index.ts:70 所有已加载消息,按消息 id 存
messageOrder web/lib/session-store/index.ts:72 每会话的消息 id 有序列表
pendingProjectsByChat web/lib/session-store/index.ts:81 未发送会话选定的项目,按临时 chat key 存
runningTasks web/lib/session-store/index.ts:90 每会话运行任务;驱动各自 composer 的发送/停止按钮
trees web/lib/session-store/index.ts:96 每会话最新的实时 context 树
tokens web/lib/session-store/index.ts:103 每会话 token 用量
contextWindow web/lib/session-store/index.ts:112 每会话上下文窗口大小
heads web/lib/session-store/index.ts:115 每会话当前 DAG head(选中的分支尖端)
additionalWorkingDirsBySession web/lib/session-store/index.ts:146 每会话附加工作目录
composerDrafts web/lib/session-store/index.ts:182 每会话未发送草稿文本,持久化到 localStorage
composerSettingsBySession web/lib/session-store/index.ts:194 每会话工具开关/思考强度,持久化
contextPanelFor web/lib/session-store/index.ts:211 /context 浮窗开在哪个会话上——单字段当按会话标志用,见第 5 节。已删除:现在是每会话 store 上的 contextPanelOpen

pendingDecisionsweb/lib/session-store/index.ts:244)是个值得单独说的混合体: 它是扁平的 FIFO 数组,但每一项自带 sessionIdweb/lib/session-store/types.ts:60),composer 在 web/components/chat/composer/index.tsx:344 把队列过滤到自己的会话。功能上已经按 会话隔离,结构上是一份需要每个消费方都正确过滤的列表。

B 类 —— 全局单例,但语义属于某个会话#

这是问题集合。每一项都是单个值,但它描述的东西属于某一个具体会话或某一个具体窗格。 只有一个会话可见时看不出来;有两个时,第二个窗格要么覆盖第一个,要么被迫读第一个的值。

字段 声明位置 为什么应该按会话隔离
currentSessionId web/lib/session-store/index.ts:74 "那个"活动会话。两个窗格时有两个,其中一个只是聚焦的那个
activeChatKey web/lib/session-store/index.ts:77 同上,用于未发送草稿的临时 local_* id
runningTask web/lib/session-store/index.ts:86 已废弃,应读 runningTasks[sid];保留只为让旧的 setRunning(false) 调用方还能用。已删除。
composerInput web/lib/session-store/index.ts:178 聚焦会话的活动草稿;是 composerDrafts[focused] 的镜像。已删除。
composerSettings web/lib/session-store/index.ts:193 聚焦会话的活动设置;是 composerSettingsBySession[focused] 的镜像。已删除。
composerFocusTick web/lib/session-store/index.ts:206 自增计数器,用来让"那个"composer 聚焦输入框;两个 composer 时无法确定谁该响应
fnFormFunction web/lib/session-store/index.ts:217 哪个函数的参数表单替换了输入框。属于某一个 composer,不属于整个应用
fnFormPrefill web/lib/session-store/index.ts:226 该表单的预填参数
fnFormForkOf web/lib/session-store/index.ts:227 重跑时的 fork 锚点节点
fnFormClosing web/lib/session-store/index.ts:235 该表单的关闭动画标志
welcomeVisible web/lib/session-store/index.ts:165 聊天区是否显示欢迎屏——这是每窗格的条件
transcriptLoadingId web/lib/session-store/index.ts:172 只存一个在途会话 id;两个窗格可以同时在加载
branchInfo web/lib/session-store/index.ts:62 "当前会话"的分支 chip
statusBadge web/lib/session-store/index.ts:65 顶栏状态标签;由某一个会话的运行状态推导
paused web/lib/session-store/index.ts:92 暂停标志,原理上应按运行中的会话分
providerInfo web/lib/session-store/index.ts:94 顶栏显示的当前会话 provider/模型
detailNode web/lib/session-store/index.ts:261 右栏显示的选中 DAG 节点
nodeSelected web/lib/session-store/index.ts:271 "有 DAG 节点被选中"的闸门

detailNodenodeSelected 列在这里是因为它们描述某个会话的 DAG,但它们是明确的 非目标——见第 9 节。

C 类 —— 真正应该全局#

这些属于应用本身而非任何会话,应该保持单值。

字段 声明位置 是什么
wsStatus web/lib/session-store/index.ts:48 WebSocket 连接状态
agentSettings web/lib/session-store/index.ts:51 Chat/Exec 模型徽标,镜像自 window._agentSettings
conversations web/lib/session-store/index.ts:68 侧栏的会话列表(所有会话的目录,不是某个会话的视图状态)
rightDock web/lib/session-store/index.ts:256 右侧栏展开/收起及当前视图,持久化到 localStorage

3. 其他 store#

除主 session store 外,web/lib/state/ 下还有若干较小的 store。它们都不在会话隔离的 关键路径上,但知道各自管什么可以避免以后重复造状态。

  • web/lib/state/center-tabs-store.ts(1020 行)—— 标签条与窗格布局:tabsactiveIdgroupssplitWebTabIdsplitRatioweb/lib/state/center-tabs-store.ts:129)。这是视图状态,全局是正确的:它描述 窗口,不描述会话。它也是唯一知道"当前存在分屏"的 store,所以作用域树的会话 id 从这里来。
  • web/lib/state/center-tab-groups.ts —— 对标签布局的纯函数(分组、重排、分屏窗格)。 自身无状态。
  • web/lib/state/chat-scroll.ts —— 按 chat key 存的滚动位置助手,通过一个存储接口 持久化(web/lib/state/chat-scroll.ts:37)。天然按会话分,只是不在 store 里。
  • web/lib/state/functions-store.tsskills-store.tsplugins-store.ts —— 页面级清单及其筛选/排序/搜索的 UI 状态。确实是全局的; 这些是设置页,不是会话。
  • web/lib/state/files-shared.ts —— 项目列表、文件读取、按路径分键的文件草稿 (web/lib/state/files-shared.ts:144)。按文件分,不按会话分。

4. 遗留的 window.*#

还有第二套更老的状态层,早于 React store:直接挂在浏览器 window 对象上的可变属性, 由 web/lib/runtime-bridge/ 下的模块读写。类型就地声明,比如 web/lib/runtime-bridge/chat-handlers.ts:34web/lib/runtime-bridge/conversations.ts:75

  • W.currentSessionId —— 遗留层对"活动会话"的记法。
  • W.conversations —— 一份重量级的每会话表,存完整消息数组,与 store 里轻量的 conversations 摘要表不是同一个东西。
  • W.isRunning —— 单个全局"有东西在跑"标志。
  • W.__sessionStore —— 让遗留代码伸手进 React store 的逃生口,用在 web/lib/runtime-bridge/chat-handlers.ts:895

两层的关系#

它们是手工保持同步的双轨系统。WebSocket 帧到达 runtime bridge,bridge 更新 window.* 的值,同时写穿到 React store。web/lib/runtime-bridge/conv-store-mirror.ts:4 明确写了这一点:侧栏读 store.conversations,所以每一处改动 window.conversations 的地方都必须同时走这个镜像,才能让 store 保持权威。

大部分进入的帧是用遗留全局而非 store 做闸门。data.session_id === W.currentSessionId 这个模式贯穿 web/lib/runtime-bridge/chat-handlers.ts(第 226、249、251、271、451、 524、588、747 行)和 web/lib/runtime-bridge/conversations.ts(第 332、387、527、530、 536 行)。每一处都是一个"针对非聚焦会话的事件会被丢弃或错投"的地方。

哪些已经绕开了。 分屏窗格完全不走遗留 shell。 web/components/chat/peer-session-pane.tsx:1 解释了原因:遗留的 #chatView shell 是 单例,键在写死的 DOM id(#chatArea#chatMessages)上,被大约十个 runtime-bridge 模块读取,因此不可能挂载两次。分屏时 AppShell 把它整个隐藏 (web/components/app-shell.tsx:552),每个窗格改为直接从 store 渲染纯 React。窗格 甚至自己发 load_session 请求(web/components/chat/peer-session-pane.tsx:66),因为 遗留路径里没有任何东西会去加载非聚焦的会话。

哪些还在用。 非分屏路径的消息渲染、会话切换、分支徽标刷新,以及大部分 WebSocket 帧路由。遗留层没有死;它是默认路径,分屏才是绕开它的例外。


5. 全局单值会导致的失效模式#

每个 B 类字段都以同一种方式失效:

组件渲染了两份,但它读的状态只有一份。

两份 <Composer /> 挂载。两份都执行 useSessionStore((s) => s.composerInput)composerInput 只有一个。于是要么两个窗格显示同样的文字,要么后写的赢,要么在右窗格 敲的键出现在左窗格。类型系统不会报任何警告,因为在分屏窗格里读全局字段和在单窗格里读 一样合法。bug 只在运行时、只在分屏、且只在特定交互序列之后才显现——这就是它们一个一个 冒出来而不是一次全暴露的原因。

实例:contextPanelFor#

/context 徽标位于 composer 内部,点击弹出该会话上下文构成的分解浮窗。

如果写成一个普通的全局布尔值 contextPanelOpen,分屏会渲染两个徽标,都订阅那一个布尔 值。点任意一个徽标会同时弹出两个浮窗,关掉一个则两个都关。

全局分键的答案是:不存"开没开",改存开在哪个会话上

/** /context 浮动弹窗:存"哪个会话的面板开着"(null = 关)。分屏时
 *  两个 composer 各渲染一个 badge,按会话区分才不会两边同时弹。*/
contextPanelFor: string | null;

消费方随即与自己的会话 id 比较(web/components/chat/context-badge.tsx:44):

const panelOpen = useSessionStore((s) => s.contextPanelFor != null && s.contextPanelFor === sid);

这就是全局分键模式的缩影,而且它能跑:每一份渲染出来的副本都问"这是我的吗?",只有一个 回答是。

它同时也是第 6 节决定不推广的那个模式。每个消费方仍要跟对的 id 比较,而跟错的 id 比较 ——或者跟聚焦会话比较——在类型上依然合法。在每会话 store 下,这个字段就是 contextPanelOpen,一个属于徽标自己那个会话的普通布尔值,没有比较可以搞错。

镜像更糟#

镜像——一个全局字段存着分键表里聚焦会话那一项的副本——比普通全局值更糟。 composerInputcomposerSettings 就是这样的两处。每次写都必须更新两份副本,每次切 会话都必须把镜像换过去(switchChat)。

镜像还会渗进组件,组件不得不按"是否绑定了明确的会话"分支 (web/components/chat/composer/index.tsx:166):

const input = useSessionStore((s) =>
  bound === null ? s.composerInput : (s.composerDrafts[bound] ?? ""),
);

同样的三行三元在 fastunattended 上重复,在 composer-session.tsx 里又重复一次。 每一次重复都是一次把兜底写错的机会,而且 bound === null 那一支意味着写错的后果是 "静默用聚焦会话",而不是报错。跨窗口搬移会话的路径(desktop-bridge.tstab-transfer-journal.ts)也不得不重建这份镜像。

在第 6 节的方案下,活值归作用域所有,于是没有镜像可言、没有兜底分支,传输路径也没有 东西需要重建。


5b. 业界的三种做法#

凡是"同一种界面可以同时开好几份"的软件都要解决这个问题,主流做法有三种。

做法一:每个页面一个独立小仓库。 不设全局仓库,而是每开一个编辑器/面板就现场创建 一份专属状态对象,关掉就销毁。VS Code 是典型——它可以左右开无数个编辑器分组,每个分组、 每个编辑器实例都有自己的状态对象,互相完全不可见;"当前聚焦哪个编辑器"是唯一的顶层信息。 Zustand(我们用的状态库)官方也支持这种"每实例一个 store"的用法。优点是隔离最彻底,缺点 是跨页面共享的数据(比如会话列表)还得另设一层,两层之间要接线。

做法二:一个全局仓库,内部按 ID 分格 + 组件树挂作用域标签。 仓库还是一个,但所有 实例相关的数据都存成 { id → 数据 } 的字典;组件树的根上用 React 的 Context(就是我们 的 Provider)声明"这棵树属于 ID 几",树里组件自动读自己那格。Redux 官方推荐的 normalization(按 ID 归一化存储)就是这个思路;数据请求库 TanStack Query 更彻底,所有缓存 一律按 key 分格,根本不存在"当前那份"的概念。Slack、Discord 这类多频道界面基本都这么做。 优点是共享数据和实例数据在一个仓库里,调试方便、跨会话功能(侧栏列表、全局搜索)好写; 缺点是纪律要靠约定,谁偷偷读了"当前会话"的全局指针就又退化回去。

做法三:物理隔离。 浏览器的做法——每个标签页干脆是独立进程,状态想串都串不了。 Chrome 是最极端的例子:主进程管标签条/书签/设置等全局态,每个标签页的网页内容跑在独立的 渲染进程里,进程间只能走 IPC 消息。它付这个代价有特有的理由——跑的是不受信任的第三方代码, 隔离同时是安全边界(Site Isolation 防 Spectre 类攻击)。应用内分屏跑的都是自己的代码, 不需要为"数据别串"付进程级的内存和通信成本,没人这么干。

本项目最终选做法一(见下节),做法二作为曾考虑的替代方案记录在 6 节末尾。


6. 方案:每个会话一个独立 store 实例#

每个会话拥有自己的 Zustand store 实例。真正共享的数据留在唯一的全局 store。

组件不指名会话,也不读全局活动切片。它只读"我的草稿",由外层的 SessionScopeProvider 决定那是谁的。于是"同一个组件对两个会话各渲染一份"变成了普通情形,而不是会出事的情形: 两份订阅的是两个不同的 store,抢不起来,因为根本没有共享的东西可抢。

划分依据是归属,不是存储形状:

内容 访问方式
全局 store wsStatusagentSettingsconversations(列表)、messagesByIdrightDock,以及需要持久化的按会话分键表(composerDraftscomposerSettingsBySessionrunningTasks useSessionStore
会话 store 实例 本会话的 draftsettingsrunningcontextPanelOpen useSessionScope,须在 SessionScopeProvider
视图态 标签条、窗格布局、分屏比例、哪个窗格聚焦 center-tabs-store;属于窗口,不属于会话

组成部件#

web/lib/session-store/session-scope-registry.ts 放 store 工厂和模块级 Map<sid, store>。实例会缓存,窗格卸载不销毁——切走标签再切回来,正在打的草稿还在。 只有会话本身被删除时才丢弃(dropSessionStore,由 removeConversationdropChatDraft 调用)。

web/lib/session-store/session-scope.tsx 是 React 层:SessionScopeProvider sid=…useSessionScope(selector)

不存在无绑定路径。 组件不在 provider 内时 useSessionScope 直接抛错,而不是回落到 聚焦会话。静默兜底正是这一层要消灭的 bug,而且它只会在分屏、且经过特定交互序列后才暴露; 抛错让漏包在第一次渲染时就现形。现有两个 provider 覆盖了全部 composer: web/components/app-shell.tsx 里的 FocusedComposer 用聚焦 chat key 包住单会话 composer,PeerSessionPane 用各自的会话 id 包住每个分屏窗格。

为什么用实例,而不是全局分键切片#

两种形状都能止住冲突。选实例是因为它消除了写出这个 bug 的可能性,而不只是消除当下 这几处。全局分键切片下,每个消费方在每次读写时仍要自己给对键;给错了键——或者干脆不给、 落到聚焦会话——在类型上依然合法。用实例,键由 provider 给一次,组件没有办法误指到别的 会话。

订阅粒度也天然更窄:一次按键只通知本会话的订阅者,不需要对选择器格外小心。

需要持久的状态仍归全局 store#

实例持有的是活状态。任何需要活过本标签页的东西——localStorage 持久化、跨窗口搬运的 线上格式、侧栏的跨会话视图——都留在全局分键表里。两边双向对齐:

  • 实例 → 全局。 实例的 setter 先更新自己(窗格同一 tick 重绘),再写穿到全局分键 setter。这些钩子由全局 store 在模块初始化时通过 installScopeWriteThrough 装上, 正是这一点让 import 保持单向。
  • 全局 → 实例。 setComposerInputForsetComposerSettingssetRunningTaskFor 都会调 pushToSessionStore,于是 WS 帧、遗留桥接、跨窗口搬标签也能落进活实例。

实例首次渲染时从全局表取种子值,这是"刷新后重新挂载能看到持久化草稿而不是空框"的原因。

没有镜像字段#

因为活值归实例所有,全局 store 不持有"聚焦会话的活切片"这份副本。没有 composerInputcomposerSettingsrunningTask,也没有 contextPanelFor。聚焦会话的草稿就是 composerDrafts[activeChatKey],在需要的地方算出来,而不是存两份。

曾考虑的替代方案:全局仓库 + 作用域标签#

另一条路是保留单个全局 store,把每个按会话的字段都做成 Record<sid, T>,再用一个 React context 提供键,让消费方读 map[scopeKey] 而不是读全局。contextPanelFor 就是 这个模式的缩影:它存的是哪个会话开着面板,每个徽标自问"这是我的吗?"。

它能跑通,改动也更小。没有采用,是因为它把失效模式留着了:键仍然每次访问都要传,漏传 仍然静默地表示"聚焦会话",而且类型系统区分不了一次正确的读取和一次串了作用域的读取。 它对的那部分——用并列的 Record<sid, T> 而不是一张嵌套的 sessions 表、messagesById 保持扁平且全局——原样保留,因为这些表正是实例赖以持久化的东西。


7. B 类字段各自的归属#

每个 B 类字段都成为 SessionScopeState 上的一个属性,由 useSessionScope 读取, 下面两处例外除外。

  • fnFormFunction / fnFormPrefill / fnFormForkOf / fnFormClosing 合成作用域上 的一个 fnForm 属性。侧栏和收藏列表在打开表单时并不知道该由哪个窗格承载,因此 它们指定一个明确的目标会话——这是一个真实的行为决策,不是机械映射。消费方: web/components/chat/composer/modes/resolve-mode.ts:18web/components/chat/composer/modes/fn-form/use-fn-form-state.tsuse-fn-form-wrapper.tsweb/lib/use-pending-run-function.tsweb/components/sidebar/favorites-list.tsxweb/components/sidebar/sidebar.tsxweb/components/chat/messages/runtime-block.tsxweb/lib/runtime-bridge/functions-panel.ts
  • welcomeVisibletranscriptLoadingId 是每会话布尔值,消费方为 web/components/chat/welcome-screen.tsxweb/components/chat/messages/message-list.tsx
  • composerFocusTick 是每会话计数器,这样聚焦一个窗格的输入框不会把另一个拽走。
  • branchInfostatusBadgepausedproviderInfo 按会话分。它们喂给顶栏,而顶栏 显示的是聚焦会话,所以顶栏读聚焦作用域。
  • currentSessionId / activeChatKey 留在视图层,作为全局聚焦指针。它们不表示 "那个会话",而是表示"聚焦窗格的会话",这正是 center-tabs-store 已经用 activeId 跟踪的东西。

pendingDecisions 的路由依赖这套作用域划分正确:它在 web/components/chat/composer/index.tsx:344sessionId 过滤,作用域搞错的 composer 要么吞掉另一个会话的提问,要么把它显示两遍。

8. 遗留的 window.* 层是过渡态#

第 4 节描述的 window.* 层不属于这套设计,它正是 store 要取代的东西。下列条件全部成立 后即可移除:

  1. web/lib/runtime-bridge/chat-handlers.tsconversations.ts 里每一处 data.session_id === W.currentSessionId 闸门都成为按 data.session_id 分键的 store 写入,让后台会话的帧能落地而不是被丢弃。这要求 store 先把"聚焦"和"那个会话"区分开, 也就是第 7 节的聚焦指针所做的事。
  2. window.conversations 没有任何 store 按键表覆盖不到的读取方,此时 web/lib/runtime-bridge/conv-store-mirror.ts 就不再是必需的,可以直接删掉。
  3. W.isRunning 没有读取方;runningTasks 覆盖它。
  4. 单例的 #chatView DOM shell 消失,换成 PeerSessionPane 已经证明可行的 React 路径。 这是最重的一条——大约十个 runtime-bridge 模块靠写死的 id 访问那个 shell。

闸门移除出错的表现是静默丢 WebSocket 帧,而不是渲染出明显错误的东西,因此每移除一个 闸门都需要配套验证那个帧仍然到达它的会话。

9. 非目标#

右栏与 DAG 保持单份,跟随聚焦会话。 detailNodenodeSelectedweb/lib/session-store/index.ts:261:271)保持全局。没有任何东西把它们渲染两份: 右栏只有一个 dock(web/components/right-sidebar/right-sidebar.tsx:407:575),也没有 让每个窗格各有一份 DAG 的计划。它们继续读聚焦会话就是正确的。

rightDock 本身、顶栏徽标的显示(按定义就是显示聚焦会话)、以及设置页的各 store, 同理。

持久化表保持原形状。 全局 store 里并列的 Record<sid, T> 多张表原样保留;它们正是 每会话实例取种子值和持久化所依赖的东西。把它们合成一张嵌套的 sessions 对象没有计划, 对正确性也不是必需。

两个以上窗格不在范围内。 作用域机制碰巧让 N 个窗格也能工作,但这里没有任何东西是 按 N > 2 设计或验证过的。


附录:实现状态#

第 6 节的作用域基础设施已就位——session-scope-registry.ts(store 工厂、实例表、 写穿钩子)和 session-scope.tsxSessionScopeProvideruseSessionScope);镜像也 已消失:全局 store 里不再有 composerInputcomposerSettingsrunningTaskcontextPanelFor,线上格式(SessionTransferSnapshotChatTransferState)也不再 携带聚焦会话分键项的副本。

尚未落地:

  • 第 7 节——剩余的 B 类字段仍在全局 store 里。每个都是搬到 SessionScopeState 上, 不是新造机制,大约十五到二十个文件,可拆成五次独立可验证的提交。fn-form 那一组最大, 因为有八个文件消费它。
  • 第 8 节——遗留 window.* 层仍是非分屏路径的消息渲染、会话切换、分支徽标刷新以及 大部分 WebSocket 帧路由的默认路径。退役它涉及整个 web/lib/runtime-bridge/(十三个 模块加 dag 子目录),也是唯一一处"做一半比两个端点都差"的地方,因此排在最后, 按模块以一次持续的推进完成。

相关#

Last updated · 2026-08-13