dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
Right-click any user message to edit it and retry from that point — a client-side plugin for DeepSeek Harness (DSH). · 右键编辑任意一条用户消息并从那里重试。
重发任意一条用户消息。 右键会话里的一条消息,可以重试,也可以先改再重试 —— DSH 都从这条消息之前的位置分叉出一个新会话,把文本当作第一句话发出去。「编辑并重试」的编辑发生在窗口底部那个自带的输入框里,按发送的那一刻才分叉。
一个 DeepSeek Harness(DSH)客户端插件。
在会话记录里右键点击任意一条用户消息,会弹出一个菜单:
| 菜单项 | 行为 |
|---|---|
| 重试 | 原样重发这条消息,不做任何编辑 |
| 编辑并重试 | 把原文放进窗口底部的输入框,你在那里改完按发送 |
| 重试后删除原会话 | 一个开关,勾上后上面两条动作完成后会删掉源会话,并让新会话继承它的名字 |
「编辑并重试」不在气泡上开编辑器了 —— 它用的是 DSH 自带的那个底部输入框(就是平时打字发消息的地方),跟元宝的「编辑」一个逻辑:
所以「点了菜单会不会就分叉出去一个空会话」的答案是不会:在按下发送之前,什么都没发生;改主意了,把输入框清掉就行,什么都不会留下。
「重试」这一项不走输入框,还是老样子:直接分叉并把原文发出去。
选了「编辑并重试」之后,对话区会蒙上一层模糊(半透明 + 背景模糊),只有底部输入框和正在编辑的那条消息是清楚的 —— 跟元宝一样。视线自然落到你正在打字的地方。
Esc 退出编辑模式:模糊撤掉,输入框里的内容清空,挂起的重试也作废(不然下次发送会把它当普通消息发出去)。
模糊的做法是照抄元宝的做法(从它的页面上读出来的,不是猜的):在内容容器上加一个遮罩类,用一个伪元素做背景模糊 ——
.agent-chat__list__mask .agent-chat__list__content::before {
backdrop-filter: blur(7.5px);
background: rgba(255,255,255,0.5);
inset: 0; content: ""; position: absolute; z-index: 11;
}
—— 而它的输入框在 z-index: 12,所以输入框在遮罩上面。
DSH 的布局容得下同一套安排:遮罩是 chat 区域上的一个伪元素(这个区域本来就是 position: relative,同时装着对话和输入框),输入框那格被提到遮罩之上。产品自己的规则一行没动 —— 输入框那格的 z-index 只在「有回合在跑」时才有,所以插件只在编辑模式期间给它补一个。
正在编辑的那条消息豁免(打一个属性标记,位于遮罩之上):它是你唯一还在看的东西,糊掉它等于把正在改的字藏起来了。
两条动作的后续完全一样:
整个过程复用的是 DSH 自带「分支」按钮的同一条路径;唯一多出来的动作,是下面两节说的那两件事 —— 把源会话当前的模型和权限预设显式带到新会话上。
选择后菜单就关闭了,因此进度和失败会显示在气泡下方(「正在重试…」/「正在删除原会话…」/「正在继承原会话名…」/「重试失败:原因」/「删除原会话失败:原因」/「改名失败:原因」)。走输入框的那条路径,进度和失败显示在输入框下方(「已放入底部输入框,修改后按发送即可重试」/「正在重试…」/「重试发送失败:原因」)。
插件要动的是 DSH 自带的那个输入框,不是自己画一个。两处接口(都读自 DSH 自带的包,不是猜的)让它成立:
conversation.chat.node 是一个 session 作用域的槽位,所以气泡本来就能拿到这个会话的标准 props,其中就有 inputActions —— 输入框自己的动作接口。inputActions.setDraft(text) 是往输入框的真实编辑器里写字的正规路径(光标落在末尾)。于是「选中这一项 → 原文进输入框」不需要插件模拟任何键入。conversation 服务把输入 hub 挂在自己的 input 字段上,而 input.for(会话作用域) 解析出那个会话常驻的输入 shell。这个 shell 就是自带输入框拿到的 keyboard,而 shell.submit 是所有发送的唯一起点 —— 回车键位(installDraftKeymap)和发送按钮(onPrimary)都走它。所以插件在 shell 实例上改写 submit,自带输入框(编辑器、模型控件、权限控件、附件栏、dock、回车、发送按钮)原样渲染、一行不改。于是流程是:选中「编辑并重试」→ 文字出现在底部输入框 → 你在那里改 → 按发送时才分叉,新会话收到的就是你批准的那段文字。
插件刻意不遮蔽任何输入框槽位。 遮蔽
conversation.composer.bar这条路走不通:遮蔽的条目既不继承自带条目的inject(keyboard就来自那里),也不继承它的children(自带InputBar需要它:模型控件、权限控件、附件栏、dock 都是通过那些子槽位渲染的,渲染器在组件要一个「自己的条目没声明」的子槽位时会抛SlotOwnershipError),而重复声明那些 children 会被注册表直接拒绝(「槽位已被声明」)。这个失败从外面看像什么都没发生:遮蔽的条目根本没进槽位,自带输入框原样回来,插件一声不吭。所以插件改的是 shell 上的方法,不是槽位。
包
shell.submit时有一处容易踩的坑:那个 shell 是个类实例,submit会写它自己的实例字段(this.attachmentFlightSeq、this.attachmentFlights)。所以包装对象不能靠原型链糊弄过去(那样this会指向包装对象,shell 自己的计数器就停在旧值上),必须把方法绑回真实 shell,并把实例字段做成透读(shell 是重新赋值这些字段的,复制一份会读到过期值)。附件栏也一样:读的是 shell 自己的attachmentIds字段,不是snapshot里那份副本。
补丁只在真的挂起了一次重试时才装(也就是用户选了「编辑并重试」的那一刻),而且是每个会话各装一次 —— 包装器每次发送都问一句「有没有挂着重试」,没有就原样放行。
分叉点默认是「这条消息的前一个事件」(seq - 1),而它必然落在消息自己那个 turn 内部:DSH 先 turn/start、再把提示词 splice 进 inbox,最后才写下转录用来挂气泡的 user/message。切在半个 turn 上,DSH 的 buildForkSeed 就会用 openTurnClosers 补一条合成的 step/end + turn/end {kind:"forked"} —— 背后没有任何用户消息,转录把它渲染成重试内容前面的一行空「用时 N 秒」(实测跨度十几毫秒,取整显示成 1 秒)。
所以插件把分叉点改到上一个 turn 的 turn/end(一个平衡边界,openTurnClosers 扫完发现没有开着的 turn,就不补任何东西),前提是这条消息开了它自己的 turn,并且满足下面几条:
turn/end。它同时也在这条提示词的 inbox splice 之前,所以子会话不会重发原文(跟第一条消息改走「新建会话」是同一个理由)。turn/end 是宿主补出来的合成回合(reason: {kind:"forked"})→ 跳过它,取再上一个真跑完的回合。合成回合是上一次重试的残留,宿主用它来闭合切点落在半截的那个 turn;而转录会把合成回合渲染成一行空的「已完成,用时 1 秒」。切在它后面就会继承它 —— 于是每次重试都继承上一次重试留下的那行,每重试一次多一行(自己延续自己)。切点必须落在真正跑过的回合上。seq - 1。被用户中断(aborted)的 turn 会把已 splice 进 inbox 的提示词留在队列里,等下一个 turn 才取走;切到那个 turn/end 会把这条源会话从没回答过的文本一起继承给子会话,让它先重发一遍。所以分叉前先数一遍 inbox 的净存量(Σinserted − ΣremovedCount),非空就不切。seq - 1。它前面没有任何已闭合边界,往前切会连带丢掉同一 turn 里更早的消息,那比多一行严重。hasMore)→ 同样保持 seq - 1:看不到开头就既不能断言「这是第一条」,也不能信一个自己看不见起点的边界。跑在你现在选中的那个模型上 —— composer 里显示的是哪个,重试就用哪个。
这件事需要显式做,因为 DSH 的两条规则叠在一起会得出一个意外结果:分叉只继承到重试点为止的事件前缀,而宿主是按会话自己的日志决定模型的(该会话最后一条 request/header 的路由,或一条尚未被请求消费掉的 model/selection)。于是「先切了模型,再往上重试一条旧消息」——也就是最自然的用法——子会话会跑在那条旧消息当时用的模型上,跟你眼前选中的那个无关。
所以插件在分叉之前读源会话的 modelSelection 投影(next:有待生效的选择就是它,否则是最后一次请求的路由,正是 composer 模型控件渲染的那个值),并在这个新会话的第一条提示词之前调用宿主那条 session/selectModel —— composer 模型控件用的也是同一条 RPC。宿主把它记成一条 model/selection 事件,于是子会话的记录、模型控件、以及真正跑起来的模型三者一致;历史是别的模型生成的时,DSH 自己还会在记录里插一条「model changed」提示,把这件事讲清楚。
跑在你现在选中的那个权限预设上 —— composer 里权限控件显示的是哪个,重试就用哪个。
原因和模型那条一样,但后果更实在:沙箱模式和审批策略都是会话事件(sandbox/mode、approval/policy,再加上把两者打包成一个身份的 permission/preset),所以分叉只继承到重试点为止的那些事件;而第一条消息走的是「新建会话」,新会话拿到的是部署默认预设。于是「先切到完全权限,再往上重试一条旧消息」会让子会话跑在旧的(或默认的)权限下 —— 而且屏幕上不会说,直到你打开这个子会话才发现。
所以插件在分叉之前读源会话的 permissions 投影(currentValue,正是 composer 权限控件渲染的那个值),并在新会话的第一条提示词之前切过去。切的动作用的是 /permission <预设> —— composer 权限控件提交的就是同一行,于是走的还是产品自己的路径:宿主把预设记成 permission/preset,再把变化的那些旋钮(沙箱模式、审批策略)分别交给各自的 setter,子会话的记录、控件和真正生效的权限三者一致。
custom(表示「当前旋钮组合不对应任何预设」)时不切:custom 不是一个可切换的目标,硬发过去只会换来一条失败记录。/permission 是按空白切分的命令行,那样的名字只会被拼成一行乱码。预设键由部署方决定,非 ASCII 的名字(比如 完全权限)不受影响,可以正常带过去。删除是可选、永久的,所以它默认关闭、状态记在 localStorage 里(勾一次就一直是勾上的),而且只在重试真的发出去之后才执行 —— 重试失败会保留源会话,因为那时它是这段对话唯一的副本。
删除成功后,新会话会改名成被删掉的那个会话的名字:源会话没了,它的名字就空出来了,与其让新会话顶着分叉自带的「原名 (1)」,不如直接继承。名字在删除之前读取(删除会把标题所在的投影一并清掉,之后再读就是空的),改名走 session.rename 契约 —— 也就是分叉路径自己 increaseTitle 内部用的同一个调用,所以侧边栏和标题投影会同步更新。源会话在目录里查不到标题时跳过改名,改名失败只在气泡下方报「改名失败:原因」,不会影响已经完成的删除。
DSH 没有给客户端插件任何删除会话的能力:workspace 面只提供 archiveSession(可逆,日志还在盘上),agent 协议的 session_delete 是给外部 ACP agent 用的,而会话存储根本没有公开的删除接口。所以删除做在宿主半边(src/index.js),顺序是有讲究的:
ctx.agents.get(id).cancel() 再等它静默(最多 15 秒);ctx.sessions.flush(),否则 disposes 会在日志删掉之后又把它写回来;sessions.detachEntered();~/.dsh/sessions/<slug>/<id>/,裸 uuid 和 session- 前缀两种拼写都删;第 5 步的顺序不是随便排的:半删除的会话比没删的更糟 —— 日志还在但记账没了,会话会掉进 Ungrouped;记账还在但日志没了,侧边栏会留一条永远打不开的记录。
⚠️ 这段逻辑走的是 DSH 内部结构(
sessions.store/detachEntered/storageDomain的表形状 / 磁盘布局),其中detachEntered连 DSH 自己都注明「the store has no public remove API」。每一处都做了能力探测、探测不到就跳过,所以 DSH 升级后最坏是拒绝删除或部分删除,而不是崩溃。但它确实可能在升级后失效,届时看气泡下方的错误信息。
删除走一条宿主注册的 HTTP 路由(POST /dsh-edit-retry/session/delete),要求一个自定义请求头 —— 这不是装饰:自定义头会强制 CORS 预检,而本服务不应答预检,所以外部页面无法调用这条破坏性路由。会话 id 在进入任何文件路径之前会先按白名单字符校验,并且每个候选路径都会再验证一次真的落在 ~/.dsh/sessions 之内。
已知小瑕疵:删除后侧边栏那条旧记录可能要等下一次刷新才消失(DSH 没有给客户端提供刷新会话列表的接口,而整页 reload 会打断刚发出的重试流)。点它不会有反应,重新加载即可。
DSH 的持久化日志是只能追加的(seq = log.length),而且会话记录只渲染追加来源的 surface 事件。所以原地替换 surface 只会让模型看到的内容变了、而屏幕上还是旧文本 —— 一种静默的不一致。分叉没有这个问题,因为它是产品自己就在用的做法。
插件是装进某个 profile 的普通依赖,并在该 profile 的 dsh.profile.bundles 里登记一层:
dsh plugin --profile <你的 profile> add github:huangmiuXyz/dsh-edit-retry
dsh plugin 就是把参数转发给该 profile 目录里的 pnpm,装完再自动把新依赖登记进 dsh.profile.bundles —— 本包声明了 dsh.bundle,所以这一步不用手改 package.json。重启 DSH(或刷新 Web 界面)即可生效。
为什么是
github:而不是裸包名? 本包还没发布到 npm,add dsh-edit-retry会 404;GitHub 源是当前唯一可用的写法。包内没有prepare/构建脚本,所以也不会被 pnpm 的构建脚本审批(allowBuilds)挡住。
桌面端(DSH Desktop):
--profile desktop由 Electron 应用独占,dsh plugin会直接拒绝(profile "desktop" is managed exclusively by the Electron application),所以桌面端这份要在应用内的插件管理入口里装同一个 GitHub 源。
如果该 profile 的
dsh.profile.bundles里有解析不到的条目(典型是指向已删除目录的link:包),dsh plugin会在包装完之后的登记阶段报错退出、不写 bundles —— 依赖其实已经装好了。清掉那条悬空条目再跑一次,或者直接按下面的手动做法补 bundle。
cd ~/.dsh/profiles/<你的 profile>
pnpm add github:huangmiuXyz/dsh-edit-retry
然后编辑该 profile 的 package.json,把包名加进 dsh.profile.bundles(顺序即层级顺序,插件放在内置 bundle 之后):
{
"dsh": {
"profile": {
"bundles": [
"@deepseek-ai/dsh-base",
"@deepseek-ai/dsh-web-app",
"dsh-edit-retry"
]
}
}
}
开发时用 link: 更顺手,改完代码刷新页面就生效,不用重新装包:
dsh plugin --profile <你的 profile> add link:/绝对路径/dsh-edit-retry
bundle 登记同上 —— dsh plugin 会自动做,手动做法见上面的折叠块。
steering 节点;它和开启 turn 的那条一样是人写的消息,右键菜单完全相同。区别只在分叉点:插话前面没有已闭合的边界,所以它保持 seq - 1(见下文),代价是子会话里会多一行空的「用时 N 秒」。{type:'image', attachment:{attachmentId,…}}),而 session/prompt 只接受内联字节,所以重试会通过源会话自己的 readAttachment 把每张图读回来(授权按源会话的日志校验,因此必须在删除原会话之前),转成 base64 内联重发,由宿主再落成持久引用。走输入框那条路径里,图片是先进输入框自己的附件栏、再由会话服务按产品自己的方式序列化的 —— 所以附件栏里看到什么,发出去的就是什么。某张图读失败只损失那一张:打一条警告后跳过,重试照常发出 —— 用户要的是重发这条消息,不是校验它的附件。文件搬不过来:宿主只接受由某次 agent 作用域上传签发的 receiptId,而客户端无法读取已存文件的字节去换一张新回执 —— session.attachment 是图片专用(授权走 imageBlockIn,只匹配 type === 'image'),其余远程方法都不读附件字节。因此带文件的消息保留原生右键菜单。custom、或切换被拒时退回前缀继承(新建会话则是部署默认)的那个,重试本身不受影响。locale 服务的实时快照。conversation.chat.node 里 user 和 steering 这两格都注册在默认优先级 0(同一个组件 UserMessageNodeView)。本插件用同样的两个 key、优先级 -1 各注册一次:渲染器按优先级取每一格的第一个未被让位的条目,所以更小的数字会遮蔽自带的那条。随后插件通过 ctx.slots.entries() 取出自带组件本身来渲染,因此气泡样式、图片渲染、复制和时间戳仍然归产品所有。conversation 服务把输入 hub 挂在 input 字段上,input.for(会话作用域) 解析出常驻的输入 shell;那个 shell 就是自带输入框的 keyboard,而 shell.submit 是所有发送的唯一起点。插件在 shell 实例上改写 submit,所以自带输入框(编辑器、模型控件、权限控件、附件栏、dock、回车、发送按钮)原样渲染。遮蔽 conversation.composer.bar 走不通:遮蔽的条目既不继承自带的 inject 也不继承它的 children,而重复声明 children 会被注册表直接拒绝 —— 失败的样子是一声不吭地什么都没发生。steering(见 message.ts 从 next-step inbox 认领的规则),只遮蔽 user 会让这类消息悄悄留在自带条目上 —— 菜单不存在,也没有任何提示。这正是「插话右键没反应」那个 bug 的成因。locale 座位。 entry.locale 是渲染器注入 t 翻译函数的依据,自带气泡的操作栏会调用 t;只转发 props 而不给这个座位会让自带节点崩溃。两个格子各转发各自那一条的座位。react-dom),所以菜单全部用朴素元素加 --dsw-* 主题 token 画出来,因而自动跟随明暗主题。sessions.fork、uiWorkspace.openSession、session.prompt 都在客户端服务上,所以重试本身不需要宿主;宿主这边除了让客户端模块扫描发现这个包(并发出 exports["./client"]),还注册了那条删除路由。客户端和宿主是两个不共享 import 的模块图,路由和请求头在两处各写了一遍,自检里有一条专门断言这两种写法一致 —— 否则这个功能会永远静默 404。modelSelection 投影读(客户端上就有一份,和 UI 显示的是同一帧),写则要走宿主那条 session/selectModel,因为模型的归属在宿主:只有它能给会话追加 model/selection 事件。这一路是能力探测式的 —— 探测不到 Remote 命名空间就只少这一项功能,菜单和重试照旧。permissions 投影的 currentValue)和一条写(/permission <预设>,也就是 composer 权限控件提交的那条命令);沙箱模式和审批策略各自的 setter 是宿主内部的,客户端插件够不着,所以插件不去碰它们,而是把源会话当前的预设名用同一条命令交给子会话 —— 子会话里留下的命令节点,和你手动切一次留下的是同一个。同样是能力探测:客户端 Session 没有 command 这个方法就只少这一项功能。仓库自带一个零依赖的离线自检,覆盖清单字段、槽位遮蔽与优先级、菜单交互、两种重试形态(新建 / 分叉)、输入框那一半的语义(不挂起时不接管发送、别的会话不触发、只分叉一次、发出去的是发送那一刻输入框里的文字)、分页窗口判断、删除路由的守卫与真实文件系统行为,以及浏览器真正收到的「多模块拼接成单个 classic script」的投递形态:
node test/check.mjs
退出码非零即失败。测试里的假对象刻意照着真实契约写 —— 一个凭空发明数据形状的假对象什么也验证不了。删除那部分用真的临时目录跑:DSH_HOME 指向一个 mkdtemp 出来的目录,里面放好真实的会话目录,所以被验证的是真正的文件系统行为,不是假的对象。
针对 DSH 的 client-modules 协议构建:一个 classic script,通过 window.__ModuleLoader__.load({ id, factory }) 注册工厂,只从 react 取依赖。它依赖 conversation.chat.node 槽位、客户端 sessions / uiWorkspace / locale 服务、conversation 服务的 input 字段(输入 hub,用来拿到会话的输入 shell),以及 node.data 形态的 Chat 视图节点。删除功能另外依赖宿主的 webServer(可选,没有就不注册路由)以及若干内部结构 —— 见上文的风险说明。
Resend any user message. Right-click a message in the transcript to either retry it as-is or edit it first — either way DSH forks a new session from just before that message and sends the text as its first prompt. The edit happens in the shipped composer at the bottom of the window, and the fork happens when you press send. A separated toggle below the two entries can also delete the source session once the retry is in flight, and the retry then inherits its name.
A DSH plugin with both halves.
seq = log.length) and the transcript renders only append-origin surface events, so an in-place surface replace would change what the model sees while the screen kept the old text. Forking is exactly the path the shipped "branch" button uses.request/header route, or an unconsumed model/selection). So the most natural flow, switching models and then retrying an older message, used to run the child on the model that older turn happened to use. The retry therefore reads the source's modelSelection projection — exactly what the composer shows — and installs it on the child through the same session/selectModel RPC the composer's model control submits through, before the child's first prompt. When the child already resolves to the same route nothing is written; when the route is gone (or the session is held by another writer) the retry is still sent and only a console warning is left behind. One consequence worth knowing: the Host treats that RPC like a manual pick and also saves the selection as the deployment default.sandbox/mode, approval/policy, bundled into an identity by permission/preset), so a fork inherits only the ones in force at the retry point and a fresh session is stamped with the deployment default. Switching to full access and then retrying an older message therefore used to run the child under the older (or default) permissions, with nothing on screen saying so. The retry reads the source's permissions projection — exactly what the composer's permission control shows — and switches the child with /permission <preset>, the very line that control submits, so the Host records the preset and hands each changed knob to its own setter. A target that already resolves to the same preset is left untouched; a preset that is custom, unreadable, or refused is reported and swallowed, because this carry-over is an add-on too. The UI asks you to confirm enabling full access once; a retry does not ask again — you already confirmed and selected that preset in the source session. Like a manual switch, it leaves one command node in the child's transcript.dsh plugin --profile <profile> add github:huangmiuXyz/dsh-edit-retry, then restart DSH. The CLI forwards to pnpm in the profile directory and registers the bundle entry for you. The bare name does not resolve yet — the package is not on npm — and the Desktop app's own profile (--profile desktop) refuses the CLI, so install it there through the in-app plugin manager instead.conversation service carries the input hub as its input field, and input.for(sessionScope) resolves the resident shell. That shell IS the keyboard the shipped composer receives, and shell.submit is where every send starts (the Enter keymap and the send button both call it). So the plugin patches submit on the shell instance and the shipped composer keeps rendering untouched — editor, model control, permission control, attachment rail, dock, Enter handling, send button. The bubble writes the text in through inputActions.setDraft (the chat-node slot is session-scoped, so it already receives the composer's action face). Shadowing conversation.composer.bar cannot work: a shadowing entry inherits neither the shipped entry's inject (where the keyboard comes from) nor its children (which the shipped InputBar needs), and re-declaring those children is rejected by the registry — which looks like a no-op, because the shadowing entry never enters the slot.session.rename contract — the very call the fork path's own increaseTitle makes — so the sidebar row and the title projection stay in step. A source the catalog has no title for skips the rename, and a refused rename reports "Could not rename the retry" without disturbing the completed delete.steering node and gets the same menu, its only difference being the fork point — it keeps its predecessor, because it has no closed boundary in front of it; right-click is the only entry point; interactive descendants keep their own context menu; blank text disables Retry while Edit and retry stays available — unless the message carries an image, which is a message on its own; the first human prompt opens a fresh session while later prompts fork at the previous turn's closed boundary; the retry adopts the source's currently selected model and permission preset, falling back to the inherited (or deployment-default) one whenever either cannot be carried.sessions.store / detachEntered / storageDomain shapes / the on-disk layout). Every step is feature-probed, so an upgrade degrades this to a refused or partial delete rather than a crash — but it can break. Also, the sidebar row for a deleted session may linger until the next reload; DSH exposes no client-side refresh, and reloading the page would interrupt the retry that was just sent.{type:'image', attachment:{attachmentId,…}}), and session/prompt accepts an image only as inline bytes — so the retry reads each one back through the source session's own readAttachment (authorized against that session's log, hence before the optional delete), re-encodes it as base64, and re-uploads it inline; the Host turns it back into a durable reference on the way in. Part order is preserved by an untouched retry, and an edited one collapses the text parts into the editor's single field. A read that fails costs only that image — it is warned about and dropped, and the retry still goes out, because the user asked to resend the message, not to validate its attachments. Files cannot be carried: the Host accepts a file only as a receiptId minted for one agent-scoped upload, and nothing on the client can read a stored file's bytes to mint a fresh one — session.attachment is image-only (it authorizes through imageBlockIn, which matches type === 'image' alone), and no other remote method reads attachment bytes. A message carrying a file therefore keeps the native menu.user and steering chat-node cells at priority -1 and renders the shipped component through ctx.slots.entries(), forwarding each cell's locale seat so the shipped bubble keeps its t. Both cells are required even though the shipped registry serves them from one component: the seat d