# omdsh-basemode [English](README.md) | 中文 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 网页版的会话模式系统:所有模式插件都要注册进去的分段注册表、渲染它们的开关,以及侧栏上的两种标记——一种说明这段会话属于哪个模式,一种说明你此刻看的到底是哪一段。 **它自己不发明任何一个模式。** **Chat** 随 [omdsh-chatmode](https://github.com/omdsh-plugins/omdsh-chatmode) 到来,**Code** 随 [omdsh-codemode](https://github.com/omdsh-plugins/omdsh-codemode) 到来,两者在这里谁也不比谁更"原生"。它唯一贡献的姿态,本来就在屏幕上:**Work**,harness 自己的会话列——把它做成一个分段,只是为了让开关有个地方可以**切回去**。它只在需要时才出现,见[基线姿态](#基线姿态)。 ## 它提供什么 | 界面 | 从哪来 | |---|---| | `sessionModes` 服务 | `ctx.provide`,每个模式插件注册自己的分段都要走的那扇门 | | 模式开关 | `shell.overlay` 里的一个条目——ui-layout 那层横跨整个框架的浮层;它对准会话列居中,没人伸手时自动让位 | | **Work** 分段——harness 自己的列 | `registerBaseline`:这个包为自己做的一次注册,一旦有模式插件带来这个姿态,就立刻撤走 | | 侧栏每行最前面那个按模式着色的圆点 | `row-marks.ts`——**画**在 harness 自己的行上,不是渲染出来的;由注册表的 `tone` 和 `owns` 驱动 | | 新建会话落在一段真正**新建**的对话上 | 同一处改写:没有分段接下的请求会新建一段,而不是复用工作区里那段旧的空白对话——这样它的行才会出现在组首 | | 侧栏的高亮,落在会话列正在**显示**的那段对话上 | 同一支画笔——只在会话列与选中项不一致时才写;「会话列不是网页对话」的模式,正是在这种情况下把 harness 自带的高亮落在了原地 | | **新建会话**先递给正占着列的那个模式 | 对 `workspaces.startSession` 的一层接管:先问激活的分段要不要,没人接才交给框架,并把这件事广播出去 | | `sessionModes.column` —— 会话列此刻真正显示的是什么 | 激活的分段自己声明的 `scope`,没声明就是选中的那段对话 | harness 一行没改。它注册的那个槽位是公开座位,接管只是往原型方法上盖一个自有属性,撤掉这一行,两样都原样奉还。 它也不注册任何 settings 命名空间,这是刻意的,不是漏掉的。分段注册表身上,没有什么是需要人来配置的——哪个姿态占着列,由每个标签页自己推导;存在哪些姿态,是 profile 的属性——所以它在插件中心里的卡片没有表单。 ## 为什么要单独成包 它原本是 Chat 模式的一部分,而那是一次分层倒置,代价很具体。 模式插件离不开注册表。要是注册表装在某一个具体模式里,其他每个模式想往开关上放一颗药丸,都得依赖**那个模式的整个包**——它的托管工作区、它的 agent preset、它输入框上方那行说明。"想要 Code 模式却不想要 Chat 模式"是无解的。而且这样组出来的 profile 不只是少一个分段:`omdsh-codemode` 的浏览器半边会因为一个没人组装的服务永远停在 `pending`,客户端的启动审计会因此让整个页面失败。 把座位和姿态拆开,依赖关系才诚实:每个模式插件依赖这个包,这个包不依赖任何模式,"存在哪些姿态"也变成了 profile 的属性,不再取决于注册表碰巧归谁所有。 ## 基线姿态 开关得有个能切回去的地方。以前,只装了这个包和 `omdsh-codemode`、别的什么都不装的 profile,拿到的是一个只有 **Code** 一颗药丸的控件——只有一个分段,进了终端就出不来,侧栏里每条会话也都没有圆点,因为没人认领它们。而缺的那个姿态,从来不该由某个插件来贡献:它就是 harness 自己的会话列,那块本来就在那儿的屏幕。 所以这个包自己把它注册进来,名字沿用 `omdsh-chatmode` 给它起的——**Work**——措辞、颜色和图标也照搬那个插件的,让人看不出开关上这个分段是哪个包给的。按下它,就是把列交还回去,显示当前选中的对话;它不启动什么、不记住什么,也不需要 profile 提供任何东西。 它只在需要时才立在那儿,这句话的两半同样重要: - **别的什么都没注册时,就根本没有开关。** 只有基线一个分段时,开关不渲染——只有一段的控件没什么可切的。这也守住了当初的承诺:装了模式系统、却没装任何模式插件的 profile,屏幕上什么都不显示。 - **有人认领"其余一切"这个姿态时,它让位。** `omdsh-chatmode` 的 Work,就是这个姿态**外加一份"你上次离开的是哪段对话"的记忆**,所以渲染出来的是它那一段。任何声明了 `fallback` 或占了同一个 id 的注册都会顶掉基线,那个插件一卸载,基线立刻回来。两个"其余一切"分段,会让同一段对话挂上两种颜色的圆点。 它的 `active` 是推导出来的,不是写进去的:只要它立在那儿、而且没有贡献者拿走列,列就归它。正因如此,模式插件无论是崩了、卸载了,还是仅仅自己不再 active,列都会自动交还,不用谁惦记着去还。 ## 契约 一个模式插件注册一个分段,自己的事自己答: ```ts // 绝不写进顶层 inject —— 见约定第 9 条。 ctx.inject(['sessionModes'], (mctx) => { const modes = mctx.get('sessionModes') as SessionModes | undefined if (modes === undefined) return mctx.effect(() => modes.register({ id: 'code', order: 20, label: t('mode.code'), // 已经是读者的语言 hint: t('mode.code.hint'), tone: 'var(--dsw-alias-state-error-primary)', icon: createElement(IconCodeOutline16, { size: 14 }), owns: isCodeSessionId, // "这段对话是我的吗?" available: true, enter: () => { /* 一次按下所执行的导航 */ }, newSession: (workspaceId) => { /* 自己起了一个就返回 true */ }, })) }) ``` `SessionModes` 是一个类型,来自 `@omdsh-plugins/omdsh-basemode/client`。用 `import type` 引入,永远不要作为值引入:跨插件的**值**导入,要么把这个包的运行时再内联一份进你的产物,要么去问 shell 那张冻结的模块表要一个它答不上来的 specifier,而客户端产物的纯度门会因此让构建直接失败。上面那段代码之所以用字符串 `'sessionModes'` 解析服务,而不用这个包导出的 `SESSION_MODES` 常量,原因也在这里:服务名是与运行时共享的**线上名字**,不是与某个包共享的符号。本集合里的两个模式插件,都为此各自把它写成字面量。 动手之前,有五件事值得知道。 **同一时刻只有一个分段是激活的**,这条规则由注册表强制执行,不靠贡献者自觉:激活一个,就清掉其余所有。贡献者也是靠它得知自己丢了列的——看着自己那条变成 false,就把自己的界面收起来。 **文案递进来时就已经本地化好了。** 开关照单渲染 `label`、`hint` 和 `unavailableHint`;它自己只有两个词(`switch.aria`,以及某个模式不可用又没说原因时的兜底),不知道任何模式叫什么。记得在 `locale/change` 时重新 `update` 你的分段。 **按下是一次导航,绝不是一次状态写入。** `enter` 被调用后,这个分段要负责把世界变成真的——打开一段对话、起一段新的、或者把列拿过来——然后由负责推导它 `active` 标志的那一方去汇报。这里没有任何东西跨刷新记住模式;**故意**不做 node 半边,所以也不存在一份能让两个标签页吵起来的存储姿态。 **`owns` 是按显示顺序逐个会话问的**,第一个认领的赢;标了 `fallback` 的分段,接走所有没人认领的。侧栏就是这样按模式标满整张列表的,而画这些行的代码,对任何具体模式都只字不提。某个判别器抛了异常,只代表它放弃这段对话,不会把整个浏览区拖下水。 **`inProject` 说的是一个模式的对话是不是住在有人干活的地方。** 默认是 true,因为这个产品里的一段对话本来就是这样;把自己的对话归档进自己那套存储的模式,才声明 `false`——Chat 就是那一个。任何**从屏幕上这段对话推导目录**的界面都要读它:以前在一段聊天旁边按 Code,终端会开在聊天归档的那个文件夹里。去问注册表,正是为了把「这几个里哪个是 Chat」挡在其他每个插件之外。 ## 「列在显示什么」和「选中了什么」不是一回事 `sessions.current` 回答的是"哪段对话被**选中**了",而它跟"屏幕上是什么",只在所有模式的列都是网页对话时才是同一个问题。Code 模式的列是终端,而且它**故意**从不选中终端驱动的那段对话——被选中的对话,是 Web host 会去 resume 的对话,而那份日志归另一个进程所有。于是选中项停留在终端背后打开的那段对话上。 所以,立在列旁边、却去读选中项的界面,不会缺一块,而是错得**无声无息**:`omdsh-sidepanel` 的文件树曾立在一个项目的终端旁边,描述的却是另一个项目。`column` 才是那个诚实的答案: ```ts // 列不是网页对话的模式,自己声明它在显示什么。 modes.register({ id: 'code', /* … */, scope: controller.scope }) // 列旁边的一切都跟着它走,而不是跟着选中项。 const scope = modes.column.getSnapshot() // { sessionId, cwd } ``` **只在该分段激活时才读**,这是刻意的:贡献者的 scope 无论占没占着列,通常都是活的(Code 模式会一直推导它**将会**显示什么),把它报出去,就是在描述一块没人在看的屏幕。没声明 scope 的模式——Chat 和 Work,它们的列**就是**网页对话——按选中项报告,所以消费者只需要一条路,不用两条。 ## 点一条会话,意思是「让我看它」 光靠选中项承载不了这个请求,而这道缝隙是个上手一分钟就能撞见的 bug:从某条工作对话进入 Code 模式——那条对话仍是**选中**的,因为终端不是它——然后去侧栏点它那一行。运行时选中的是已经选中的东西,什么都没变、什么都没发布,终端继续盖在这次点击想看的那条对话上面。想回去,就只剩模式开关可点,而这不是点侧栏一行的含义。 所以这里给 `sessions.open` 包了一层:每次 open,都把列交给"显示这条对话的那个模式"(`showConversation`),不管选中项动不动。声明了自己 `scope` 的模式会被跳过——这种点击它自己会先接住(`omdsh-codemode` 对自己的那些 id 就是这么做的),能走到这里,说明它已经拒绝了。 ## 新建会话属于按下它时所在的那个模式 "再来一段和这个一样的对话",在不同姿态下含义不同:随包发布的那几个模式,要的是框架的空白会话;列里跑着终端的姿态,要的是框架压根没听说过的东西。所以这个请求先递给当前激活的分段(`newSession`),没人接,才落到框架手里。 分段一旦拒绝,就连同请求一起把列交出去——这是对的默认:用户要的是一段对话,而框架马上就要显示一段。 落到框架的这条路,会广播出去(`onNewSession`),因为这个手势可能是唯一什么痕迹都不留的导航:已经站在某个工作区的空白对话上时按下新建会话,不移动任何选中项、不改任何列表、不发布任何 store。一个从"用户在哪"推导自己标志的模式,会继续汇报那段用户正想离开的对话。**"问了"这件事本身,就是全部的事实。** ### 而且它落在一段真正新建的对话上 框架自带的这个手势会**复用**工作区里已有的空白对话,而把那一行摆错位置的,正是这个复用。浏览列表只把两种行提到组首:账上从没见过的,和 `updatedAt` 变大的。被交回来的对话两条都不沾,于是停在原位——排在第五位之后,折叠起来的组根本不画它。按下新建会话却看不到新行,不过是同一件事的另一面。 所以,没有分段接下的请求会去**新建**一段对话(`sessions.create`),不再复用。复用只留给它唯一正确的场景:那一行已经在屏幕上。站在某个工作区的空白对话上再按新建会话,要的就是已经打开的这一段,所以什么都不发生——这也是连按几下不会变出好几段对话的原因。 代价是:按了新建会话、没说话、又走开,会留下一段空白对话。它看不见(除了当前那一行,列表不画任何空白行),跑出第一轮之前不往磁盘写任何东西,而框架启动时的复用会把它消耗掉。 ## 安装 ```sh dsh plugin --profile web add @omdsh-plugins/omdsh-basemode ``` 这个包已经发布,所以写名字就够了——不用 git specifier,也不用 pnpm 构建白名单。这次安装也可以只是点一个按钮:只要 profile 里已经有插件中心,按钮就在**设置 → 插件 → 插件中心**里这个插件的卡片上。 把开关填满的那些模式插件,走[插件中心](https://github.com/omdsh-plugins/omdsh-plughub)的命令——它会从这套集合的 registry 里解析: ```sh npx @omdsh-plugins/omdsh-plughub add omdsh-chatmode omdsh-codemode ``` 两个都是可选的,开关如实反映组进来的东西:只装这个包,什么都不显示;加上 `omdsh-codemode`,显示 **Work · Code**;加上 `omdsh-chatmode`,显示 **Chat · Work**;两个都装,则是 **Chat · Work · Code**。 顺序只是可读性上的偏好,不是要求:模式插件是在受限 fiber 上按名字解析 `sessionModes` 的,所以排在这个包前面的,只会等,不会失败。 也可以从检出安装,你在改的那份构建要走的就是这条路。`dsh web` 启动前 `lib/` 必须存在——loader 直接 import `lib/index.js`,而按路径安装的包不会跑 `prepare`,没有任何环节替你构建: ```sh pnpm install && pnpm run build dsh plugin --profile web add "$PWD" ``` 同理,改完源码要重新构建。 卸载同理: ```sh dsh plugin --profile web remove @omdsh-plugins/omdsh-basemode ``` 之后每个模式插件都会自己安静下来——分段和开关没了,各插件其余的界面照常站着。这就是受限 fiber 买来的东西,也是"模式关掉了"和"页面死了"之间的差别。 ## 命令 ```sh pnpm install pnpm run build # tsc 产出 lib/types,tsdown 打包浏览器半边 pnpm run typecheck # 先包源码,再测试 pnpm run test # vitest ``` 这个包对着哪个 harness 编译,是可以切换的: ```sh pnpm run harness:npm # 提交状态:锁定的已发布版本 pnpm run harness:local ../../deepseek-harness # 同级检出,用于开发 pnpm run check:harness-pin # 只要还有 link: 就失败 ``` **只有 registry 状态可以提交。** `link:` 是相对声明它的那份 manifest 解析的,提交一条,就等于把某台机器的目录布局写死进包里——而且 pnpm 不会大声报错:它建出悬空符号链接、报告安装成功,然后构建阶段每个 harness import 都是 `TS2307`。`check:harness-pin` 就是用来在提交前拦住这件事的。 ## 已知限制 - **开关的座位是借来的。** `shell.overlay` 也横跨侧栏和详情面板,所以这枚药丸自己对准带 `data-conversation-scroll` 的盒子居中,指针不在附近时就隐藏。不带这个属性的列会让它失去锚点,开关便弹到整个框架的正中间。 - **行上的标记是画上去的,不是渲染出来的。** 侧栏的行是 harness 的,所以两个标记——模式圆点和会话列的高亮——都是通过 `MutationObserver` 写到 DOM 上的,不是组装进去的。harness 改了行的结构,这个包就得跟着改选择器。 - **标记读的是一个私有结构。** 一行是哪段对话,取自它的 React props——那不是公开契约。将来浏览器改版或 React 换了形状,结果是行上不标,而不是标错,开关里的字形也不受影响。另一条路——按行标题匹配——比不标更糟:同一个项目里,没标题的会话彼此同名。 - **搜索结果不带圆点。** 圆点画在浏览用的行上;搜索结果是两行的堆叠,第二行本来就写着所属工作区。 - **没有行的对话拿不到高亮。** 高亮是**移到某一行上**的,所以模式正在显示的东西,如果会话列表压根没听说过——比如第一轮还没落盘的 Code 终端——侧栏就一行都不高亮。等这段对话落盘、行出现了,高亮才会落上去。 - **移过来的高亮,盖得过一行自己的菜单。** 压掉 harness 自带的高亮,靠的是一条优先级更高的背景规则,而它多管了一种状态:一行如果同时是选中的、不在会话列上、又展开了自己的省略号菜单,那么在这三件事都成立期间,它会失去菜单那份背景色。菜单本身不受影响。 - **按了新建会话又不用,会留下一段空白对话。** 按下、什么都没说、又去了别处,那段对话就留在工作区的账上,等着框架启动时的复用把它捡走。在那之前,它哪儿都不画,也没有任何东西落到磁盘上。 - **没有模式能活过一次刷新。** 激活的姿态,是每个标签页从"当前对话住在哪"推导出来的,各贡献者各推各的。打开应用,永远落在当前对话所指的地方,而不是你上次待的地方。 - **基线的 Work 什么都不记。** 按下它,只是把列交还回去、显示当前选中的对话——它不会带你回到上一段工作对话;新标签页里什么都没选中时,落到的是工作区选择页。那份记忆是 `omdsh-chatmode` 的 Work 的,这也正是装了那个插件后由它的分段顶替这一段的原因。