omdsh-basemode
English | 中文
DeepSeek Harness 网页版的会话模式系统:所有模式插件都要注册进去的分段注册表、渲染它们的开关,以及侧栏上的两种标记——一种说明这段会话属于哪个模式,一种说明你此刻看的到底是哪一段。
它自己不发明任何一个模式。 Chat 随 omdsh-chatmode 到来,Code 随 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,列都会自动交还,不用谁惦记着去还。
契约
一个模式插件注册一个分段,自己的事自己答:
// 绝不写进顶层 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 才是那个诚实的答案:
// 列不是网页对话的模式,自己声明它在显示什么。
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),不再复用。复用只留给它唯一正确的场景:那一行已经在屏幕上。站在某个工作区的空白对话上再按新建会话,要的就是已经打开的这一段,所以什么都不发生——这也是连按几下不会变出好几段对话的原因。
代价是:按了新建会话、没说话、又走开,会留下一段空白对话。它看不见(除了当前那一行,列表不画任何空白行),跑出第一轮之前不往磁盘写任何东西,而框架启动时的复用会把它消耗掉。
安装
dsh plugin --profile web add @omdsh-plugins/omdsh-basemode这个包已经发布,所以写名字就够了——不用 git specifier,也不用 pnpm 构建白名单。这次安装也可以只是点一个按钮:只要 profile 里已经有插件中心,按钮就在设置 → 插件 → 插件中心里这个插件的卡片上。
把开关填满的那些模式插件,走插件中心的命令——它会从这套集合的 registry 里解析:
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,没有任何环节替你构建:
pnpm install && pnpm run build
dsh plugin --profile web add "$PWD"同理,改完源码要重新构建。
卸载同理:
dsh plugin --profile web remove @omdsh-plugins/omdsh-basemode之后每个模式插件都会自己安静下来——分段和开关没了,各插件其余的界面照常站着。这就是受限 fiber 买来的东西,也是"模式关掉了"和"页面死了"之间的差别。
命令
pnpm install
pnpm run build # tsc 产出 lib/types,tsdown 打包浏览器半边
pnpm run typecheck # 先包源码,再测试
pnpm run test # vitest这个包对着哪个 harness 编译,是可以切换的:
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 的,这也正是装了那个插件后由它的分段顶替这一段的原因。