dsh-dschat
Chat with DeepSeek's web端 (chat.deepseek.com) inside DeepSeek Harness as a native panel, then distill or replay the conversation into a new harness session. 适合希望免API额度使用网页端,并能将对话无缝导入本地继续开发的用户。
Install
npx -p @deepseek-ai/dsh dsh plugin --profile web add github:CLnum42/dsh-DSchatREADME
Read the full README ↗dsh-DSchat
在 DeepSeek Harness 桌面版里,用原生面板聊 DeepSeek 网页端(chat.deepseek.com), 聊完把整段对话迁移成 harness 会话继续开发 —— 复用你的网页登录,不消耗 API 额度。
包名 dsh-dschat(npm 强制小写),界面上显示为 dsh-DSchat。
| 浅色 | 深色 |
|---|---|
![]() | ![]() |
思考用时(流式期间实测)、正文里的 [citation:N] 芯片、默认收起的参考来源:

这三张是真实组件在真实浏览器 + DSH 自己的主题表里渲染出来的 (
node scripts/ui-shot.mjs --synthetic),会话列表是合成样例 —— 脚本默认读本机真实记录,--synthetic关掉它,公开的图里不会出现任何人的真实对话。
与 dsh-webchat 的关系
它是 dsh-webchat 的重写版:浏览器引擎、网页端解析、蒸馏与迁移机制沿用(Apache-2.0),
但界面改成 DSH 原生面板,并把整套命名空间独立成 dsh-dschat,两个插件可以共存。
最关键的一处改动是集成方式:
| dsh-webchat | dsh-DSchat | |
|---|---|---|
| 中心面板 | MutationObserver 往中心栏塞 DOM,再用 CSS 把原生会话区 display:none | 注册 main 槽位(key dschat),真正的原生面板 |
| 侧边栏入口 | 往侧边栏插一个按钮,还要自我修复被 React 顶掉的行 | 注册 sidebar.panellist(id dschat,order 20),与「插件」「任务」同级 |
| 设置页 | 无 | 注册 settings.section(id dschat,order 50),与「通用」「模型」「插件」同级 |
| 与会话区关系 | 互斥,靠 html 属性互相驱逐 | 无关:main 面板不绑定 session,切换不打断原生会话 |
这三条都有运行时证据:安装后 cordis_inspect_query 的 Slots.listSubTree 显示
main 的 occupants 里出现 dschat,sidebar.panellist 里出现 id: "dschat", order: 20。
功能
- 网页聊天:真实浏览器驱动 chat.deepseek.com,流式回复,深度思考(R1)与智能搜索开关。
- 文件附件:「上传文件」按钮点开 macOS 原生文件对话框(隐藏的 ``,
和宿主自己的输入框走同一条路),选完即经
/attach落盘并回到输入框;粘贴(⌘V)和拖进输入区 走同一条落盘链路。图片、PDF、Word、Excel、PPT、txt、md 等都可以,单文件上限 24 MB、 单条消息最多 10 个;附件 7 天后自动清理。 这是唯一能拿到本地文件的入口——打包后的 harness 没有任何"返回文件路径"的原生对话框 (唯一的原生对话框是选目录的dsh-desktop:directory-pick),ctx.fileUpload收的是字节、ctx.conversation.pickFiles()只回一个 boolean,都不给路径。输入区那一排是两组: 左边 深度思考 → 智能搜索(这条消息要怎么答),右边 附件 → 发送(现在就动这条消息; 附件按钮只有图标,文字在aria-label/title上,位置与网页端一致——回形针是发送圆的左邻)。 - 链接一律交给本机默认浏览器:面板里没有内嵌浏览器,也没有「点开跑到别处」的歧义——
正文链接、
[citation:N]芯片、参考来源每一行都走window.open(url, "_blank"),即宿主自己的 外链通道(Electron 主进程setWindowOpenHandler→shell.openExternal),并用系统默认浏览器 打开;非 http(s) 协议(mailto:等)不接管,仍由锚点自己处理。 - 回复里的来源排在正文之后,且默认折叠:网页端在搜索结束时就编好了来源表(早于正文的第一个
字),面板在流式期间只渲染
[citation:N]芯片、不渲染来源列表,等正文结束再出一个 「参考来源(N)」标题——顺序与网页端最终的版式一致(正文 → 参考来源),而不是「先冒出参考来源、 再被正文顶下去」。列表默认收起(一次搜索可能引用二十个页面,全展开会把正文挤没),点标题展开。 - 思考段独立成行,带真实用时:assistant 消息的推理不再渲染成 ``,而是一个 「已思考(用时 X 分 Y 秒)」的行——时间是引擎在流式期间实测的(首个推理片段 → 首个正文片段, 见「思考用时」)。这一行默认折叠,屏幕上只有一行; 点它才展开,展开后只有完整推理,那一行本身不再显示,点推理内容任意处即可收起(见 「一行,或者完整内容,不会同时出现」)。推理进行中时这一行是实时的: 折叠态显示「思考中:」,单行、跟着流式内容不断左移,永远露出最新写出来的那几个字; 推理期间它处在展开态,看到的是推理全文(滚动体自动跟到底部)——那行实时摘要属于折叠态。 回答一开始就自动收成一行,换成用时。 从网页恢复的会话(历史里只有推理文本、没有时间)只显示「已思考」。正文不再被折进折叠框里。
- 消息操作:assistant 消息的「复制回复」「引用到输入框」只取正文,「复制思考过程」单独取 推理段(见「正文 / 思考必须分开」)。
- 头部三个窗口按钮:标题栏左侧是会话列表 / 搜索 / 新建对话(网页端自己的顺序与图标,
34px 方块 + 16px 线性图标)。列表可以整个收起把宽度让给对话,状态记在
localStorage(dsh-dschat.rail.open);搜索按钮会展开列表并把光标放进搜索框。 - 会话列表可拖宽:左侧对话记录栏右侧的拖拽条可以调宽度(170–460px,双击恢复默认,
键盘 ←/→ 也能调),宽度记在
localStorage(dsh-dschat.rail.width)。 - 迁移到 Harness:头部「在 Harness 中继续」,可选 蒸馏成任务简报(默认)或 原文完整迁移, 可指定目标工作区或追加到已有会话;带三段式进度(蒸馏 → 建会话 → 打开),完成后自动跳进新会话。
- 从网页恢复:三层来源(页面自己的 history 接口 → 应用 IndexedDB 缓存 → DOM 全量抓取), 单会话 70–400ms 拿回整段对话(含思考),再次恢复会把之前导入不全的记录就地补全。
- 导出 markdown 默认进「下载」文件夹:面板不再把文件丢进"最近那个工作区"或进程 cwd
(
exportDir可改,留空即~/Downloads),导完的提示给的是完整路径, 因为「已导出到 dschat-….md」等于没说文件在哪。 - 会话搜索 / 删除可撤销。
- 原生设置页:状态、运行参数(浏览器渠道、无头、超时、数据目录、profile 目录、蒸馏模型)与 快捷操作;参数本身在「插件」页的 dsh-DSchat 行里编辑。
- Agent 工具:
dschat_status/dschat_send/dschat_recover/dschat_import/dschat_transfer。
安装
用 DSH 自带的插件安装器(不要手改 profile,也不要直接在 profile 里跑 pnpm):
npm pack --pack-destination ~/.dsh/dsh-dschat-dist # 先打成 tarball
plugin_manager action=install_bundle target=file:
安装后 dsh.profile.bundles 会多出 dsh-dschat。
必须用 tarball,不要用 link:。 link: 会让 profile 里出现一个指向工作区的符号链接,
Node 按真实路径(即工作区)向上找 node_modules,插件自己的 playwright-core 与 harness
提供的 @deepseek-ai/* 都解析不到,表现为 dsh: warning: 1 entry did not activate 加上
dschat (dsh-dschat): failed to import。tarball 安装落盘的是真实目录,解析链才正确。
两个会让插件"静默加载失败"的坑
harness 的 loader 把插件入口的任何导入期异常都压缩成同一句 failed to import,
真实错误被 ctx.logger.error() 吞掉,日志里查不到。踩过的两个:
- profile 里是符号链接(见上)。
- 导入了不存在的具名导出。ESM 的具名导入在链接期解析,名字对不上就抛
SyntaxError,同样报成failed to import。所以:- 宿主半区只从
@deepseek-ai/dsh-llm/dsh-session/dsh-tools导入,且每个名字都 在真实包里核对过;@deepseek-ai/dsh-settings不导出settingsNamespace/installSettingsSection(那是臆造的 API,真实设置能力走ctx.settings服务)。 test/stubs/harness.ts里只能放真实存在的导出。桩里多一个不存在的名字, 离线测试会通过,而线上必挂 —— 这是本项目最贵的 30 分钟。- 插件能在运行时导入哪些包,由它自己 package.json 的
dependencies+peerDependencies的名字集合决定(dsh-app-boot的profileDependencyNames), 所以用到的 harness 包必须显式声明为 peer。
- 宿主半区只从
目录
src/index.ts 宿主入口:Config schema、路由、工具、system prompt 公告
src/protocol.ts 两端共享的线协议(API 路径、数据模型、阶段推导)
src/routes.ts /api/dsh-dschat/* 路由族(loopback 围栏)
src/tools.ts dschat_* agent 工具
src/transfer.ts 蒸馏 / 原文迁移 / 导出
src/store.ts 本地对话记录
src/engine/engine.ts playwright-core 驱动 chat.deepseek.com 的引擎(沿用 dsh-webchat)
src/client/index.ts 浏览器入口:locale、样式、槽位注册(panellist / main / settings.section);
工作区服务面(pickDirectory / createWorkspace)按 call 取
src/client/panel/ DSchatPanel(header / rail / thread / composer,链接外开、附件走 /attach)/
DSchatSettings / Markdown(含 Thinking 与 SourceList 两个折叠块)/
reply(正文 vs 思考)/ styles
scripts/build.mjs esbuild 构建
scripts/ui-shot.mjs 把真实面板挂进真实浏览器(Chrome)截图并量尺寸:拖宽、折叠状态、
布局断言都在这里看,单测只能证明「没崩」
test/host-smoke.test.ts 宿主半区:导入、路由、工具、附件落盘、/tail 的思考用时、loopback 围栏
test/streaming.test.ts 流式链路:增量解析器与整段解析等价、/tail 增量契约、落盘合并
test/reply.test.ts 正文 / 思考切分:引用与复制取正文,流式中不吐半截标签
test/client-render.test.ts 浏览器半区:组件真渲染 + 信封与槽位注册自检 + 外链接管 +
思考用时三种状态 + 来源列表折叠与时序 + 会话列表拖宽 +
composer 两组控件的位置 + 「点一下只启动一次」的接线
test/stale-reply.test.ts 上一轮回复不许被当成这一轮的答案:内容身份判据 + 假页面的
「只重排旧行、不给新答案」与「稍后给出新答案」两条链路
test/stale-reply-e2e.test.ts 真浏览器 e2e:本地假会话页回放同一个故障(旧答案绝不落库)
与它的反例(真正的新答案照常落库,含思考)
test/wake-e2e.test.ts 真浏览器 e2e:本地假聊天页上验证 wake 只启动一次、
「打开登录窗口」复用已登录的浏览器(不重启)
docs/screenshots/ README 顶部的三张图:真实组件 + 真实主题的渲染,
由 scripts/ui-shot.mjs --synthetic 的产物转成 1600px WebP
prototype/ 定稿的 UI 原型与截图(静态 HTML,不是插件本身)
refs/ 从 DSH 应用包中提取的主题/侧边栏样式与插件开发指南
构建与测试
npm install --ignore-scripts # 只装 esbuild 与 react(测试用,不执行任何安装脚本)
npm run build # 产出 lib/index.js 与 lib/client.js
npm test # 宿主半区 + 浏览器半区
node scripts/ui-shot.mjs # 可选:真实浏览器渲染 + 截图 + 几何断言(写 .tmp-ui-shot/)
对着官方网页端量尺寸用的几个脚本(只在需要重新量一次时跑,产物落在
scripts/ui-ref-*.mjs 的第二个参数目录里):
node scripts/ui-ref-shot.mjs # 打开 chat.deepseek.com(复制登录 profile 到临时目录),
# 截图 + 全量计算样式 report.json
node scripts/ui-ref-styles.mjs # 输入卡片 / 胶囊 / 头部按钮的完整样式与高清裁切
node scripts/ui-ref-dark.mjs # 深色主题 + 开关"关闭"态(点一下再点回来,不落设置)
node scripts/ui-ref-svg.mjs # 把官方内联 SVG 的 path 原样抠出来(icons.tsx 的来源)
node scripts/ui-ref-icons.mjs
# 把抠出来的图标放大画一遍,先看再抄
node scripts/ui-icon-check.mjs
# 把 DSchat 的标和 host 自己的图标按真实渲染尺寸排一张对比图
node scripts/token-probe.mjs # 验证 --dsw-static-deepseek-* 在宿主主题表里真的解析得到
node scripts/asar-probe.mjs [out] # 只读地从 app.asar 里抠单个文件,不解包 121 MB
# (宿主包的真实 API 以打包副本为准,见 refs/README.md)
ui-ref-* 需要登录态:脚本会把 ~/.dsh/dsh-webchat/browser-profile 拷到临时目录再打开,
因为正在运行的插件引擎锁着原目录。
静态渲染证明"组件不崩",证明不了"布局是对的":拖拽条是否真的抓得住、工具行是否还排得下、
折叠行是否真的只有一行高,都要在真浏览器里量。scripts/ui-shot.mjs 用真实组件、真实样式表、
真实词条和真实会话记录(~/.dsh/dsh-dschat/transcripts.json)渲染一页,然后用 Chrome 截图,
并把 getBoundingClientRect 的结果打成 JSON——「附件在深度思考右边」「DOM 里没有 ``」
「拖到 170px 后 localStorage 是 170」「胶囊点亮后底/边/字/图标同色」「思考中的那一行真的把尾部
滚到了可视区(translateX(-超出量))」「点了会话列表按钮后 rail 与手柄都不在 DOM 里」
这些结论都是它给出的。
加 --synthetic 才适合给别人看:不带这个参数时,会话列表从本机真实记录
(~/.dsh/dsh-dschat/transcripts.json)里取,标题属于隐私;带上它就是一份固定的合成样例,
任何机器上跑出来都一样。README 顶部那三张正是这么来的:
node scripts/ui-shot.mjs --synthetic # 产物落 .tmp-ui-shot/(含 report.json)
# 再把 01-default / 03-sources-open / 05-dark 转成 1600px WebP(q88)
# → docs/screenshots/panel-light.webp / sources-and-thinking.webp / panel-dark.webp
测试为什么长这样:@deepseek-ai/* 只在运行中的 harness 里可解析,所以宿主测试用 esbuild 把这些
specifier 别名到 test/stubs/harness.ts 再跑真 apply();浏览器测试用 react-dom/server 把两个
组件真渲染成静态标记(组件渲染抛错会清空整个槽位条目,且只有打开界面才看得见,所以必须在
这里拦住);React 必须 external,否则 bundle 自带一份 React、hook dispatcher 为 null。
桩的纪律见上文「两个会让插件静默加载失败的坑」第 2 条:桩里只允许出现真实导出。
两个 artifact 的约定
lib/index.js—— 宿主半区(ESM)。@deepseek-ai/dsh-*与playwright-core保持 external; 其余全部内联。schemastery必须内联:宿主 bundle 只被 harness 从 profile 目录导入, 裸导入schemastery在那里解析不到(这正是 dsh-webchat 把它内联的原因)。lib/client.js—— 浏览器半区,包在window.__ModuleLoader__.load({ id, factory })信封里,id必须等于包名。React 从宿主模块表require取得,所以 react/react-dom/jsx-runtime 保持 external。
依赖 profile 的一点
浏览器 profile 默认复用 ~/.dsh/dsh-webchat/browser-profile,所以从 dsh-webchat 切过来
不需要重新登录 DeepSeek 网页端(可在设置里改 profileDir)。对话记录存在独立的
~/.dsh/dsh-dschat/。
网页端选择器:只认结构,不认 class
chat.deepseek.com 用的是哈希化的 CSS-module 类名(_546d736、c08e6e93……),每次发版都会变。
[class*="conversation"] 这类猜测对当前页面实测匹配 0 个元素,而失败表现是
「从网页恢复」列表永远为空且不报错。所以:
- 侧边栏会话行 =
a[href*="/a/chat/s/"]。这是站内深链的路由契约,站点自己改不掉; 分组标题(置顶 / 今天 / 昨天)在a之外,不会混进标题;行内按钮无文字, 所以a.textContent就是标题。 - 消息列表 =
[data-virtual-list-item-key](虚拟列表的 item key,结构性而非样式类); 回复正文.ds-assistant-message-main-content/.ds-markdown,思考块.ds-think-content。 这些在真实会话页实测有效。
test/host-smoke.test.ts 有一条守卫测试,一旦有人把 class 猜测加回来就会失败。
同类陷阱:列出网页会话属于用户发起的读取,引擎冷启动时也必须先把浏览器拉起来
(listWebConversations 早先直接返回 [],于是恢复列表恒为空)。
迁移落库:sessionPersistence 是句柄制,不是服务制
ctx.get('sessionPersistence') 上没有 append。服务只负责 create / open,
两者都返回一个写句柄,append / flush / close 挂在句柄上:
const handle = await persistence.create(header)
try { await handle.append(events); await handle.flush() } finally { await handle.close() }
另外会话格式 v4 的 header 里 isSeeded 是必需字段(新建会话为 false);
漏掉它在写入时不报错,只在恢复时以 format v4 header lacks required field isSeeded 失败。
这两处都是从 dsh-webchat 原样继承、且从未真正跑通过的死代码(它那边同样调用
persistence.append)。教训:跨包服务的调法必须去读运行时的真实契约——
cordis_inspect_query 的 Service provider 会给出精确签名与方法列表,不能照抄旧插件。
test/host-smoke.test.ts 用符合真实契约的假 persistence 跑了一遍完整迁移:
把句柄上的 append 改回服务上,就会复现 TypeError: persistence.append is not a function。
迁移后的会话必须「被看见」:冷落库 ≠ 出现在 GUI
persistence.create 绕过了 ctx.sessions,所以不会触发 session/created,会话控制器也就不会
向浏览器转发 api-session/added。后果有两个,而且是同一个根因——用户报的「点完没有新会话,
更没有打开新会话」:
- 侧边栏不会出现这个会话。客户端的会话列表只在连接换代时整体重拉,平时只吃
api-session/added增量;落库不经过 live Session,就没有任何增量。 - 「迁移后自动打开」必然失败。
uiWorkspace.openSession(id)内部是sessions.retain(target), 它对列表里没有的 id 直接抛sessions.retain: unknown session;插件把这个异常包在 try/catch 里只console.warn,于是点击表现为「什么都没发生」。
修复就是补上那条缺失的通知:落库 + flush + 关闭写句柄之后,ctx.emit('api-session/added', row)
(row 即 SessionSummary:sessionId / updatedAt / running:false /
agentAvailable:false / blank:false / cwd)。这是 dsh-api-remotes 转发白名单里的公开
emit 事件,描述正是「A Session became visible」;客户端 handleSessionAdded 会 upsert 这一行,
于是这个会话既能被列出、也能被 retain。追加路径同理补 api-session/activity,否则侧边栏的排序
时间不会更新(那条写入同样没走 live Session)。守卫测试:test/host-smoke.test.ts
的 «transfer writes a cold session through the persistence write handle»(删掉 emit 即失败)。
三个配套细节:
- HTTP 响应和事件 socket 不是同一条连接,行可能晚一两拍到达,所以客户端
openSession以 60ms 重试最多 3 秒(OPEN_SESSION_ATTEMPTS),而不是只调一次。 uiWorkspace/workspaces是别的客户端插件提供的,本插件没把它们写进inject(它们缺失时面板也要能渲染)。因此必须在调用时用ctx.get取:在apply时取一次并缓存, 一旦本插件先于工作区 UI 挂载,缓存下来的就是undefined,迁移会静默不动。- 「追加到已有会话」的目标必须是真正的 harness 会话。面板原来把网页对话(
chat-…)当目标, 宿主拿去persistence.open只会得到SessionPersistenceNotFoundError: session "chat-…" not found。 现在目标来自main槽的标准 propuseSessions,且只列agentAvailable !== true的冷会话—— 活会话自己持有写句柄,追加必然被拒。两类拒绝都翻译成人话(找不到 / 正在使用中)。
UI 几条硬约定
- hover 操作按钮必须贴着它操作的内容。 assistant 消息是通栏正文、头部在左,所以按钮走头部行右端;
user 消息是右对齐窄气泡,按钮若锚在「消息框」上会跑到最左边、离气泡几百像素。因此 user 的气泡被包进
.dsh-dschat-msg-line(收缩到气泡宽),它才是按钮的包含块,right:100%才能在任何气泡宽度下紧贴其外侧。 两种角色的按钮顺序也统一为「复制」在前,位置不会在消息之间跳。 - 浮层材质只有一个来源:
--dschat-surface(派生自--dsw-specific-menu)。 不要用alias-bg-overlay:它在浅色下是不透明的#e9ecf2,在深色下是不透明的#61666b——不是浮层材质, 浅色下像背景、深色下就是面板上的一块白板(用户报的「菜单太白了」)。specific-menu是宿主自己画菜单用的 材质,并且已经带平台分支(darwin:近不透明#f8f9faf0/#303136f0)。深色菜单还要把描边换成border-l3,#ffffff0f的发丝线在深色半透明底上看不见。 - 按钮的「强调」配色必须落在 accent 上,不能用
button-primary-*家族。 该家族解析到brand-primary,是随主题反转的反色:深色下近白#f9fafb。主按钮在深色下因此成了整块面板最亮的 东西(用户报的「在 Harness 中继续太白了」)。state-business-primary才是 accent 本身 (#4176e6浅 /#7aaaff深),按百分比叠在背景上,一条规则两种主题都对。主按钮与「深度思考」开关 用完全相同的 tint——实测两者在两种主题下合成出同一个填充色(浅#e6edfc、深#222835)。 同理禁用态也不能用button-primary-dimmed(浅色浅灰、深色深灰,还是反色家族)。 .dsh-dschat button { color: inherit }会吃掉所有变体的文字色。 它的优先级是 (0,1,1),压过.dsh-dschat-toggle-on这样的单类名 (0,1,0),所以 toggle 的 accent 文字色、以及每个变体:disabled的文字色都被静默丢弃,标签回落到继承色。变体要显式声明color;:disabled的 (0,2,0) 才压得住。 排查时别只看getComputedStyle().color:color是可继承属性,而「变量缺失导致的计算期无效值」会让它 报出父元素的颜色,于是 accent 按钮会被误读成label-primary。要读就直接量截图像素。PANEL_CSS是一个反引号模板字符串,正文里不能出现反引号。 CSS 注释里写`.foo`会提前结束字符串, 剩下的被解析成减法表达式(`str`.dsh - dschat - msg - line),esbuild 会正常编译,只在运行时抛dschat is not defined,症状是面板空白。test/client-render.test.ts有一条守卫测试盯着模板体。- 消息 id 是身份,不是标签。 面板拿它当 React key、
upsertMessage拿它定位、tailDelta拿它认领「这一轮的回复」。所以同一个会话里出现两个相同 id 不是「数据难看」而已: React 的 keyed 协调会丢掉其中一个 fiber,那个消息的 DOM 节点不会被移除,而是留在被复用的父节点里 (用户报的「开新会话,结果还留着上一段对话」就是这么来的,见下文「消息 id 是身份」)。 写入侧(store 的 load / import / append)保证唯一,渲染侧(threadKeys)再兜一层。 - 图标要和宿主同一支笔:16 格网格 +
ICON_MEDIUM_STROKE。 宿主自己的图标集 (dsh-client-ui-primitives)只有两档:ICON_REGULAR_STROKE = 1(挤在文字里的)和ICON_MEDIUM_STROKE = 1.3(独立控件)。面板原来整套 1.4,比同列的侧边栏图标明显重一档, 用户报的「图标线条太粗,和 DSH 的插件图标不一致」就是这个。现在icons.tsx的默认值是 1.25, 加号 / 对勾 / 叉 / 折角这几个标记(10–14px)各自留在 1.5–1.8 并且在代码里写明理由—— 它们是标识不是图形,1.25 在 10px 上会细成一根头发。包图标icon.svg用同一套几何 (36 格 × 2.4 ≈ ChatIcon 在 16 格上的 1.07),去掉深色圆角方块背景:host 是按ROW_ARTWORK_SIZE = 30/CARD_ARTWORK_SIZE = 36把整张 SVG 放进, 带背景的方块在插件列表里像一枚 app 图标,而 host 的插件图案(pinwheel)是纯线稿。 另外里的 SVG 拿不到currentColor,也不随宿主的data-ds-dark-theme变(实测), 所以描边只能自带颜色——这里用官方插件同族的蓝渐变,两种主题下都成立。 对比图:node scripts/ui-icon-check.mjs(里面把旧图标、新图标、host 的 pinwheel / 文件夹 按真实尺寸排在一起,深色浅色各一行)。 - 颜色改动要真的量,不要靠读代码。 校验脚本用 Chromium 加载宿主的 token 表 + 本文件的
PANEL_CSS, 再从截图里取合成后的像素(半透明、color-mix、级联结果都算在内),对比度按 WCAG 公式算。 三处守卫测试(浮层材质 / 主按钮配色 / 模板字符串)都验证过「回退修复即失败」。 - 头部必须让开 macOS 的窗口 chrome 区。 桌面窗口是
titleBarStyle: "hiddenInset",红黄绿灯 (x=16 起,到 ~80px)浮在页面左上角。侧边栏展开时,宿主自己那 280px 的列顺手把中间面板推开了, 所以写死padding: 0 14px看着没问题;一收起侧边栏,该列变 0,面板直接贴到窗口左边缘, brand 图标和 "DSchat" 名字就压在红黄绿灯底下,宿主的shell.leading窗口控件(打开侧边栏 / 新建会话,88–152px)还叠在 brand 名字上——这就是用户报的「侧边栏收起后按钮不兼容」。 宿主用--dsh-frame-leading-clearance预留这条带(只在[data-sidebar-collapsed]时设在 frame 上: 160px,全屏 84px,Web/Windows 干脆不设),自己的 Conversation 头也是读它。 所以这里写padding-inline-start: max(14px, var(--dsh-frame-leading-clearance, 0px)): 收起时内容正好落在宿主的 clearance 边界(160px,全屏 84px),展开/Web/Windows 仍是原来的 14px。 实测(真 GUI +html[data-platform=darwin]):14px → 1

