# 架构决策记录

本文件只记录**当前有效**的架构决定：每条写决定、理由、代价，以及什么情况下允许重审。操作手册看 README，代理规则与工作流程看 AGENTS.md，视觉令牌看 docs/STYLE.md。

维护约束：

- 重构或拆分方案若与这里的决定冲突，先修改对应条目、写明原决定为何不再成立，再动手；不静默推翻。
- 编号是稳定标识，代码注释与其他文档按编号引用：内容随决定更新，编号不重排；一条被另一条整体取代时保留编号，写明由哪一条取代。新决定追加在文末。
- 事故经过与演变过程写在提交说明里，这里只写结论需要的理由。

按主题：

- 构建与源码：D1 拼接构建 · D5 模型文案是数据 · D18 源码布局与共用部件层 · D4 输入框样式门控
- 宿主边界：D2 只做浏览器半边 · D3 宿主选择器纪律 · D19 宿主契约与构建编号 · D10 设置传输 · D11 私有路由与字符串安全
- 运行时：D6 单一调度器 · D13 功能契约 · D12 快速失败与功能隔离 · D9 样式性能
- 功能：D14 账号表面 · D15 HDSL 契约 · D16 弹层基准 · D17 权限档位 · D20 滑动高亮 · D21 已归档列表 · D22 搜索面板 · D23 轮次状态行
- 分发：D7 行为层与皮肤同包 · D8 多主题

---

## D1. 零构建工具链：构建期逐字拼接成单文件

- **决定**：`scripts/build.mjs` 按 `FRAGMENTS` / `STYLE_FILES` 的顺序把 `src/` 下的 JS 片段与样式表拼成 `lib/client.js`。所有片段共用一个工厂作用域：不写 import/export，顶层名字在整个产物里唯一；React 由构建头部经加载器 `require('react')` 取得，其他宿主包在用到它的功能里直接 `require`。片段用现代语法（`const` / `let`、箭头函数、可选链），基础缩进 4 空格。品牌 SVG、螃蟹的帧条 PNG 与厂商锁定标在构建时内联，字体与模型文案走宿主半边的路由。
- **构建检查**：`src/` 下每个 `.js` / `.css` 都在两份清单里；`%%TOKEN%%` 全部替换；输入框样式门控（D4）；`:has()` 位置（D9）；产物能被 `vm.Script` 解析；模型文案文档校验（D5）。产物写入构建编号（D19）。
- **理由**：DSH 的插件加载器没有相对 require，也没有资产 URL，打包器产出的分块与资产引用无处安放。
- **代价**：顶层名字共用一个作用域，靠命名约定避免冲突（D18）；没有按需裁剪，产物体积靠自律控制。
- **重审条件**：加载器原生支持 ES module 相对导入与资产 URL。

## D2. 只做浏览器半边的皮肤，不改宿主

- **决定**：一切效果靠 CSS 覆盖与客户端 DOM 覆盖实现，不修改 DSH 引擎、apiproxy 与官方 UI 包。宿主半边（`host/`）只做浏览器做不到的事：提供模型文案、字体、系统用户名、HDSL 契约、用量汇总、删除会话这些私有路由（D11），以及设置表单用的 `Config`（D10）。
- **理由**：主题是皮肤，宿主升级时要能快速跟进。
- **代价**：依赖宿主带哈希的类名与界面结构，宿主改版可能打断选择器，由 D3 / D19 的纪律与 smoke、probe 兜住。
- **重审条件**：宿主开放主题 API，或为需要覆盖的区域提供插槽时，逐步迁移过去。

## D3. 宿主选择器纪律

- **决定**：
  - 定位宿主元素的优先顺序：宿主的稳定契约（`[data-slot]` 插槽定位点、宿主自己的 data-* 属性，见 D19）→ 插件在每轮刷新里打的标记 → 带哈希类名的子串匹配。
  - 子串匹配用最长稳定片段。宿主 CSS 模块的类名是 `<哈希>_<局部名>`，按组件定位时写 `[class*="_trailing"]`、`[class*="_row"]`；不带 `_` 的片段会误中别的词（`row` 命中 `…_grow` 曾把输入框高度锁在 28px；`rail` 命中 `trailing`；`tag` 命中 `stage`）与其他插件的类名。只有有意作用于全局的规则（全局字体、角标这类「全局刷子」）才用不带 `_` 的宽匹配。新增子串选择器要在真实页面里比对匹配到的元素，确认没有误伤。
  - 不按界面文字匹配宿主元素：文字随界面语言与宿主版本变化。需要区分的宿主控件由刷新按结构打标记（D19）。
  - 不覆盖 `[class*="viewArea"]` 在会话进行时的布局约定（`flex: 1 0 auto; min-height: auto`），它负责把输入框固定在滚动区底部。
  - 带宿主按钮类名的链接（`_linkButton`，例如设置页的「充值」）是填充或描边按钮，自带文字颜色；全局链接颜色排除这个类名，否则按钮文字会和填充色同色。
- **理由**：宿主类名只有局部名稳定；界面文字与短片段都已出过实际问题。
- **代价**：选择器更长；标记要等刷新写入（刷新在同一帧、绘制之前执行）。
- **重审条件**：宿主为这些区域提供稳定的 data-* 契约时全面迁移。

## D4. 输入框样式的构建期门控

- **决定**：输入框相关规则必须写在 `/* @composer-gate */` 标记之下，构建把 `[%%COMPOSER_ATTR%%]` 门控盖到标记以下的每条规则上，漏盖即构建失败。门控属性由输入框功能按「输入框重绘」偏好与当前页面写在 `<body>` 上。
- **理由**：输入框是性能与正确性最敏感的区域；偏好关闭或功能退役时，这些规则必须整体失效。
- **代价**：写输入框样式要记得放在标记之下；构建脚本维护门控逻辑。
- **重审条件**：无（这是安全网）。

## D5. 模型文案是数据，不进产物

- **决定**：`src/model-descriptions.json` 在构建时校验后复制到 `lib/`，浏览器第一次绘制选择器时经宿主路由读取。查找顺序：精确条目 → 家族规则 → 档位规则 → 目录自带文本。文案纪律：
  - 文案属于产品线，不属于某个版本：同一条产品线按名字模式映射到同一句，版本迭代与退场都不改。
  - 不写自造的档位前缀（「旗舰档：」），不重复行里已有的模型名。
  - 家族规则有顺序且必须限定范围（例如 `flash` 规则只作用于 deepseek）；「最强」「旗舰」这类最高级只允许出现在绑定具体版本号的精确条目里。
  - 同一文档的 `brands` 把模型绑定到厂商锁定标；没有规则认领的模型不画锁定标。
- **理由**：模型目录变化快，更新文案不应要求改 JS 发版；给版本写的描述最终会挂到别的版本上。
- **代价**：第一次绘制选择器有一次异步读取；文案规则有学习成本。
- **重审条件**：宿主的模型目录 API 直接提供本地化文案。

## D6. 单一调度器

- **决定**：`src/core/scheduler.js` 持有唯一的 MutationObserver（`<body>` 子树，属性只看 `aria-label` / `aria-selected`），用 requestAnimationFrame 把变化合并成每帧一次刷新，按功能顺序调用各功能的 `sync()`。全局的指针、键盘、焦点、滚动与尺寸监听也只注册在这里，经功能句柄上的钩子分发（D13）。键盘路径只认用户的按键：皮肤自己代发的键盘事件带标记（账号表面对宿主菜单派发的 Escape 带 `__dshHostMenuEscape`），调度器见到标记即跳过——那次代发只送达宿主的菜单，不得顺带触发全局 Esc 路由。刷新状态在调度器开头声明：订阅偏好时，已就绪的设置表单会同步通知并触发 `schedule()`。
- **理由**：各功能自挂观察者与监听会互相干扰、重复扫描、难以卸载干净。
- **代价**：每个 `sync()` 必须能快速提前返回，并且只在值变化时写 DOM（同值写入也会让元素样式失效）；页面持续变化时每帧都有一次刷新，其开销见 D9。

## D7. 行为层与皮肤同包发布

- **决定**：模型选择器、权限分段、账号弹层等行为层与皮肤层共享宿主定位点、调度器与弹层工具，在源码里分目录，发布为同一个包；不想要 Claude 外观的用户在设置里切换品牌。
- **代价**：「只要行为不要外观」没有独立入口。
- **重审条件**：出现第二个真实使用者（另一个主题包或宿主官方）需要复用行为层时，把它抽成独立包。

## D8. 多主题走单仓库、构建期分包

- **决定**：只有新主题出现行为上的分歧（不同的 DOM 覆盖、不同的输入框结构）时才改造：共用片段留在仓库级 `src/`，主题私有片段收进 `themes/<名字>/`，`build.mjs` 按 `--theme` 参数化，每个主题产出自包含的单文件并各自发包。只换色板与标志时，用现有的品牌切换在包内解决。
- **代价**：改造时构建脚本与目录有一次性调整；之后改共用片段需要两个主题都回归。

## D9. 样式性能：结构判断交给刷新，`:has()` 只放在最后一段

- **决定**：
  - 依赖 DOM 结构的判断在刷新里算好、写成属性，样式只读属性：输入框形态 `data-composer-variant`、附件 `data-dsh-claude-attachment`、草稿为空 `data-dsh-claude-draft-empty`、控件 `data-dsh-claude-control`（D19）、视图标签条 `data-dsh-view-tabs`。
  - `:has()` 只能出现在选择器的最后一段（`A:has(B)`、`A :has(B)`），后面不能再接后代或兄弟选择器（`A:has(B) C`、`body:not(:has(B)) C`）。构建里的 `checkHasPlacement` 拒绝违规写法。
- **理由**：在无界面 Chrome 里打开会话、每帧追加一个字（等同流式输出或打字机效果）实测：违规写法单条就让每帧样式重算多出 7–13ms，与括号里测的是结构还是 `:hover` / `:focus-within` 无关；位于最后一段的每条只有 0.1–0.5ms。按本条改完后，整张样式表下每帧样式重算 1.9–2.7ms（不加载样式表时 1.4ms），主线程每帧总耗时 5–7ms。
- **代价**：状态要等刷新写入（同一帧、绘制之前）。
- **重审条件**：浏览器对非末段 `:has()` 的失效处理实测不再明显慢于末段写法。

## D10. 设置传输只走官方 Config 表单

- **决定**：客户端只有 `configForms` 一条传输：绑定宿主实际提供的命名空间（候选依次为加载器入口 id、包名、`cordis.patch.yml` 插入的 id，以宿主的命名空间目录为准），读写都经表单控制器（值、写队列、修订号栅栏）。命名空间晚到时订阅目录，到达即绑定。宿主半边导出 `Config` 作为 schema：只有 `.volatile()` 字段进表单；schemastery 用顶层 await 加守卫导入，解析不到时 `Config` 为 `undefined`、皮肤照常加载——这是导入规定的唯一例外。设置席位注册为 `plugins.bundle.config`（键为包名）；宿主没有 `configForms` 时才注册整页的 `settings.section`。
- **代价**：`Config` 的顶层 await 让宿主半边晚一步求值（加载器本就等待导入，无实际影响）。
- **重审条件**：宿主提供不依赖 `Config` 的设置注册方式，或 `configForms` 契约再变。

## D11. 私有路由借宿主的请求栅栏；外来字符串只以文本上屏

- **决定**：
  - 宿主半边的私有路由（`/username`、`/usage`、`/session-search`、`/hdsl`、`/hdsl-skin.png`、`POST /session-delete`）处理前先调 `connection.requestRejection(req)`，拒绝即回 401/403；宿主没有该服务时，插件自带的替身只放行回环请求（回环 Host、无跨站标记、Origin 与 Host 一致）。模型文案与字体是公开的静态资产。
  - 删除会话另加四道：只接受 POST；id 必须符合宿主的会话 id 格式；正在打开的会话拒绝；目录解析后必须留在会话根目录内。删除发生在宿主半边，浏览器只提交一个 id。删除成功时宿主半边同时经 `workspaceRegistry.unarchiveSession` 把该 id 从归档集合里移除；归档集合里存储目录已消失的条目（幽灵）同样按删除成功应答并移出归档集合，否则归档列表会一直留着这行。会话根目录读不了是故障，按 500 应答。
  - 用量汇总只读：第三方插件的账本只读不写，自己的缓存放在 `$DSH_HOME/cache/dsh-claude-style/`。
  - 客户端：来自设置、账号服务、系统或其他插件的字符串只用 `textContent` 或元素属性写入；图标复制节点，不重新解析标记。
- **理由**：插件路由不在宿主自己的栅栏之内；`dsh web` 绑定 `0.0.0.0` 时局域网可直接访问，而页面能驱动执行 shell 命令的智能体。
- **代价**：路由依赖宿主的 connection 服务。浏览器同源请求带会话 cookie、桌面壳经 `forwardWebRequest` 转发到回环 Host，两种都已核对能通过。
- **重审条件**：宿主提供自带鉴权的路由注册时迁移过去，删掉本地替身。

## D12. 快速失败与功能隔离

- **决定**：
  - 出错的地方直接抛出，不吞错误、不静默回退。宿主服务可能缺席时，用可选链与 `typeof` 检查表达「没有这个服务」，不用 try/catch。
  - 功能隔离：teardown 最先经 `ctx.effect` 注册且幂等；每个功能单独安装，装不上的报告一次并退役；每轮刷新里每个 `sync()` 单独执行，连续失败 3 轮即报告并退役（`ui.retire`）。调度器自身装不上时整体回滚到宿主原样。
  - 退役就是运行该功能自己的 teardown，并交还它接管的宿主界面：页脚接管（`FOOTER_ATTR`）与输入框重绘（`COMPOSER_ATTR`）的门控强制关闭；权限控件替换的宿主控件由它自己的标记把守，随它的 teardown 交还。每个功能的 teardown 是它 DOM 与标记的唯一清理。
  - 需要隔离、但错误必须可见的地方用 `reportError`：`notifyAll` 逐个通知监听函数，某个出错就报告、其余照常；卸载时单个功能失败同样报告后继续。
  - 允许的 catch 只有以下几类，每处写明原因：上述隔离；宿主以抛错表达的预期状态（设置表单的 `set()` 拒绝不属于该配置的字段；`modelDirectories.directoryFor` 对还没建立的会话抛错，下一轮重试；`sessionQuery.readSession` 以 `SESSION_QUERY_CORRUPT_SESSION` / `SESSION_QUERY_SESSION_NOT_FOUND` 表示某个存档读不了或在列出后被删，内容搜索只把这一个会话记为空并记一条警告）；Promise 上表示「宿主半边没有应答」或「宿主自己会提示」的失败回调。
- **理由**：被吞掉的错误会在别处以更难排查的方式出现；隔离只负责不让一个功能拖垮其他功能，失败本身必须留下记录。
- **代价**：功能失效时界面上没有提示，只有控制台记录；依赖同一宿主服务的功能会一起退役（smoke 的「会话列表故障」用例覆盖）。
- **重审条件**：宿主提供插件级的错误上报时，把报告接过去。

## D13. 功能契约：FEATURES 表与调度钩子

- **决定**：
  - `src/entry.js` 的 FEATURES 表（`{ name, handle?, install }`）同时决定安装顺序与刷新顺序：刷新顺序就是安装顺序中句柄带 `sync` 的那些。
  - 调度器只认功能句柄上的可选钩子（类型定义在 scheduler.js 开头）：`sync` / `owns` + `close('outside')` / `onPointerDown` / `close('escape')` / `close('composer')` / `onInput` / `onFocusIn` / `reposition('viewport' | 'composer')` / `onCopyChange` / `onKey`；没实现的钩子直接跳过，每个功能保留自己的关闭方式。
  - `retire` 按 name 或 handle 匹配：只匹配到 handle 时只停止 sync、不拆安装（设置页的 `settingsNav`）。
  - 功能拆出的部分写成顶层工厂 `createX(...)`：状态留在工厂自己的闭包里，访问器与回调经参数传入，不伸手进别的闭包；名字以功能开头（`createAccountProfile`）。
- **理由**：加一个功能只需在 FEATURES 表加一行、在句柄上实现钩子；调度器不认识任何具体功能，没有要手工同步的清单。
- **代价**：功能内部状态经工厂参数交接，多一层间接。
- **重审条件**：钩子继续增多（例如出现第二类键盘事件或第二种观察来源）时，按事件类别分组，或让功能自己声明要订阅的触发器。

## D14. 账号表面：一套行模型，两个挂载点

- **决定**：插件的行（账号头部、其他插件的页脚条目、设置行）只出现在账号弹层里，由 `features/account/surface.js` 每轮判定挂载点。宿主有账号区时（桌面端），宿主的账号行就是入口（插件只打标记供样式重绘），插件把容器插到宿主账号菜单列表的首位，宿主自己渲染它的那几行；宿主没有账号区时（Web），插件自建账号行与弹层。宿主的行不复制、不移动、不转发点击；插件的行被点击后需要收起菜单时，派发一次带 `__dshHostMenuEscape` 标记的 Escape 交给宿主菜单处理（D6：调度器不认代发事件）。账号头部打开封号彩蛋时，覆盖全窗的浮层让指针「离开」行与菜单——悬停收起在浮层打开期间停摆，屏幕退出后页脚保持进入时的样子。
- **理由**：宿主菜单自带定位、动画、键盘遍历与关闭；插进去的行加入同一套键盘遍历，账号区不依赖宿主菜单的渲染时序与标签文字。
- **代价**：宿主菜单关闭即卸载，容器每轮重新确认并插入。
- **重审条件**：宿主为账号菜单提供追加行的插槽时，改用插槽注册。

## D15. HDSL 账号契约：宿主半边转发，浏览器半边只认一条回退顺序

- **决定**：
  - 宿主半边读 harness 启动时填好的启动环境快照（`ctx.launchEnvironment`），只信 `process` 与 `user-env` 两层（项目目录的 `.env` 会随仓库被克隆），契约版本不为 `1` 时回 `{ contract: false }`。不 import harness 的启动环境包（`link:` 安装的插件解析不到它）。
  - `GET /dsh-claude-style/hdsl` 回账号元数据，不含头像文件路径；`GET /dsh-claude-style/hdsl-skin.png` 回贴图字节，路径只来自环境、永远不来自请求。契约在进程生命周期内不变，读一次并记住。
  - 浏览器半边：昵称 = 自定义昵称 → 官方账号昵称 → HDSL 昵称 → 探测昵称缓存 → 探测昵称 → `User`；头像 = 官方账号头像 → HDSL 头像 → Claude 徽标。两者都经 `src/core/host.js` 的身份存储，欢迎语与账号行只读这一处。
  - 启动器给的是贴图集：按启动器账号列表同样的裁法取头部（脸的 8×8 贴图块按盒子 1/18 内缩，帽子层铺满），方形绘制；画完主动请求一轮刷新。
- **理由**：契约只需读一次；两条回退链收在一处，各处显示不会各自漂移；裁法与启动器一致，同一个玩家在两处长得一样。
- **代价**：桌面端的账号行由宿主渲染，HDSL 头像只作用于插件自己的表面；启动器内置的默认形象没有像素可用，回退到徽标。
- **重审条件**：HDSL 契约升级版本（按指纹取图、账号 UUID）时改接新通道；宿主提供统一的「显示名 / 头像」服务时改接宿主。

## D16. 弹层基准：一套外壳、一个停留时长、一次只开一张

- **决定**：
  - 外壳：插件自建的卡片（权限、模型、推理强度、账号、会话统计、快捷供应商）都带 `.dsh-claude-popover-card`，共用底色、边框、圆角、阴影、内边距、层级、开关动画与暗色配色；行、角标、勾号、状态行、主体用中性类名（`dsh-claude-popover-item` 等）。各功能的样式只写位置、宽度与展开方向。
  - 停留与宽限统一为 `shared/popover.js` 的 `POPOVER_OPEN_DELAY` / `POPOVER_CLOSE_DELAY`（100ms）；例外连同理由写在使用处（模型选择器两级卡片的收起宽限 150ms；会话统计的展开停留 300ms）。
  - 互斥：`registerPopover(name, close)` 登记每张弹层的关闭方式，`closeOtherPopovers(name)` 在打开前关掉其余的。一个弹层的多层卡片按一项登记；宿主的菜单由驱动它的功能登记。登记表只存关闭函数（弹层未开时是空操作），按名字覆盖登记，热重载后旧一代的函数被新一代替换。
  - hero 行一项登记对应两个宿主菜单，`hero-menu.js` 自己再做一次互斥，判定依据是「已展开的触发器」。
- **理由**：同一个交互问题分处写必然漂移；共用外观用中性类名，改一个功能的样式不会波及借用它类名的其他功能。
- **代价**：登记表是模块级状态，功能卸载时必须 `unregisterPopover`。
- **重审条件**：宿主提供统一的弹层管理（全局互斥或焦点陷阱）时改接宿主。

## D17. 权限档位以宿主目录为准，皮肤只做表现层

- **决定**：段位与弹层行都按宿主 `permissionPresets` 目录构建：目录里有的档位才画，没有的整条不出现。`PERMISSION_SEGMENTS` 只描述格子，每格列出可绑定的档位、取第一个宿主提供的（部署自己的自动档优先于宿主内置的 Auto review），一个都绑不上就不画。档位名与说明来自皮肤的 `PERMISSION_PRESETS`，不认识的档位用目录自带的名字与说明，机器值永不上屏；档位声明的 `icon` 不画，列表保持纯文字。切换走宿主的 `/permission <preset>`。
- **理由**：目录变化时不漏档位，第三方档位（例如 auto mode 插件的 `auto-mode`）成为一等条目，接入新档位不用改皮肤代码。
- **代价**：档位集合变化时重建行与段位；不认识的档位以宿主给的名字出现。
- **重审条件**：宿主原生支持档位图标、且界面有了统一的档位图形语言时，再考虑画图标。

## D18. 源码按功能归档，共用部件单独成层

- **决定**：
  - `src/core/`：宿主访问、偏好、模型文案、i18n、调度器。`src/shared/`：多个功能共用的部件，JS 与 CSS 放在一起——`dom.js`（`buildElement`、`createStamp`）、`notify.js`（`notifyAll`）、`popover.js` / `popover.css`（定位、悬停意图、弹层登记表、卡片外壳与行，D16）、`sliding-pill.js` / `sliding-pill.css`（D20）。`src/theme/`：不属于任何功能的全局外观。`src/features/<功能>/`：一个功能的安装器、拆出的工厂与样式表放在一起，主文件以功能命名。`host/`：手写的宿主半边。`lib/`：只放构建产物。
  - 拼接顺序：共用样式表排在所有功能样式表之前，功能自己的规则在同优先级时后出现、取胜。
  - 共用：同一段 DOM 查询或同一种写法出现第 3 次时收进 `shared/`（宿主访问收进 `core/host.js`）；功能之间不借用类名，共用外观用中性类名。
  - 文件大小：片段接近 750 行时，在它的功能目录里按职责拆出工厂（D13），不按行数机械切分。
  - 搬动与逻辑改动分开提交：搬动提交里只有位置与路径变化，并用构建产物逐行比对确认。
- **理由**：一个功能的 JS 与样式放在一起，读代码时只需看一个目录；共用写法只有一份，改动不会漏改。
- **代价**：路径更深；新文件要登记到构建清单（构建会拒绝漏登的文件）。
- **重审条件**：D1 的限制解除时，改为真正的模块导入。

## D19. 宿主契约与构建编号

- **决定**：
  - 插槽定位点：宿主渲染器保证每个插槽外面有 `[data-slot="<key>"]` 包装层，供外部样式定位（ui-renderer 的 scoped-slots）。例如访问模式按钮按 `[data-slot="conversation.input.permission"]` 查找，跳过插件自己插进去的按钮。
  - 控件标记：输入框的刷新按宿主 InputBar 的结构给按钮打 `data-dsh-claude-control`——`commands`（工具行里唯一打开 listbox 的按钮）、`stop` / `send`（提交区的主按钮，按图标区分：停止画 rect，提交箭头画 path；排队与插话是同一个提交按钮换了文字）、`access`（权限插槽里的按钮）。样式写成 `button[data-dsh-claude-control="…"]`，优先级与按文字匹配时相同。
  - 弹层角色随开合写撤：宿主把文档里每个 `[role="menu"]`（ui-primitives 的 `modalSelector`，与 `[role="dialog"][aria-modal="true"]` 同列）当作占据前景的菜单——快捷键派发、`closeTopModal`、Esc-Esc 停止序列与 dock 标签菜单都按它仲裁。皮肤为量宽高而常驻 `<body>` 的弹层卡片关闭时只是视觉上藏起来，仍会命中这份查询，让宿主把整套快捷键判给一个看不见的菜单；因此 `role="menu"` 经 `setMenuPopoverOpen`（src/shared/popover.js）与 `data-open` 同写同撤——卡片开着才持有角色，折起即交还。宿主自己的菜单关闭即从文档摘除，皮肤以撤销属性达到同一效果；`quickProviders` 的卡片本就开时挂载、关时摘除，无需经过它。
  - 构建编号：构建取产物内容的哈希写成 `BUILD_ID`，运行时写到 `<body data-dsh-claude-style>` 的值上；页面运行的是哪一版以它为准。热重载会替换插件代码而不重新加载页面，页面的加载时间说明不了代码版本。
- **理由**：界面文字与哈希类名随语言、版本变化，结构与契约更稳定。
- **代价**：结构判断依赖宿主 InputBar 的现状，宿主改版时需要核对；smoke 的替身宿主按宿主真实结构搭建，结构变化会先在那里暴露。宿主按 `modalSelector` 仲裁快捷键这一侧不被替身复现，冒烟改查皮肤自己的不变量：关闭的弹层卡片不持有 `role="menu"`。
- **重审条件**：宿主给这些控件提供自己的 data-* 标记时，直接读宿主的标记。

## D20. 分段控件共用滑动高亮

- **决定**：视图标签、权限分段、进行中 / 已归档与设置页的分段控件共用 `createSlidingPill`：量出当前项相对控件内边距盒的位置与宽度，写成两个自定义属性，控件的伪元素用 transform 与 width 过渡滑过去。属性与首次位置在同一次调用里写入，所以初次出现不滑入；控件隐藏或没有当前项时撤掉属性，当前项恢复自己的底色。宽度因字体加载而变化时由 ResizeObserver 重新定位。滑动不受「减少动态效果」设置影响（用户要求始终播放）。React 渲染的控件（设置页）在布局副作用里同步，赶在绘制之前。
- **代价**：每轮刷新读两个元素的位置；在已经排过版的帧里开销很小。

## D21. 已归档列表跟随宿主的两份客户端数据

- **决定**：侧栏「已归档」是插件自己的平铺列表，订阅宿主 `workspaces.list`（归档集合）与 `sessions.list`（标题、时间、来源），两者都就绪后按宿主「仅已归档」的规则重算：只收已有会话摘要、非子代理、非空白占位的归档会话，按更新时间排序，内容不变时不重建。宿主自带的筛选状态存在 ui-workspace 私有的视图 store 里，不是客户端服务，只能靠点它的菜单改，因此不用。取消归档走 `workspaces.unarchiveSession`；删除走宿主半边的私有路由（D11），宿主半边在删除目录的同时把 id 移出归档集合，浏览器删除成功后重拉一次会话列表基线，让已删除会话的旧摘要立刻离开投影、不再顶着会话树里的行；其余失败记录警告。点击已归档行显示宿主的同一条提示：用宿主的 Toast 组件与 `workspace` 文案表里的 `toast.archivedNotOpenable`（宿主的提示通道是私有的）。
- **代价**：宿主「仅已归档」的规则变化时需要同步。
- **重审条件**：宿主把视图筛选或提示通道开放为客户端服务时改用宿主的。

## D22. 搜索面板：宿主的 Modal、宿主的数据与导航

- **决定**：
  - 侧栏搜索框插在宿主品牌行（`[data-slot="sidebar"]` 里的 `_logoRow`）的品牌旁边，只在这一行带宽版品牌时放置，侧栏收起成窄条时不放；这一行改成网格，品牌与搜索框同占第一格，两者的淡入淡出交给样式表的 `[data-slot="sidebar"]:hover`，不另外监听指针。
  - 宿主的搜索快捷键（`session.search`）没有可替换的公开入口，它的效果是展开宿主自己的侧栏搜索并聚焦其输入框。宿主的这块搜索因此保持挂载、只从视觉与布局里拿掉；调度器的 `onFocusIn` 钩子见到焦点落进它的输入框时，搜索面板打开，并经宿主自己的清除按钮把它收回。这样快捷键改绑后也跟着走。
  - 面板是宿主 ui-primitives 的 `Modal`（headless）：遮罩、焦点归还、Esc 与模态层都用宿主的，面板开着时宿主的快捷键把它当作前景对话框；卡片里的行由皮肤自己搭。它是模态对话框，不套 D16 的弹层外壳，但登记进弹层互斥表。宿主的 `Modal` 关闭即卸载，没有退场动画，所以关闭时面板先给遮罩层打上标记、保持挂载淡出，淡出结束后再卸载；标记在卸载之后才撤，否则卡片与遮罩会重播一帧入场动画。
  - 数据读宿主的客户端服务：`sessions.list` 与 `workspaces.list`（去掉已归档、子代理与空白占位）、`remote.pluginInventory` 与 `remote.pluginManager`、当前会话的 `remote.skills`、`shortcuts.catalog`。远程命名空间在面板打开时用 `ctx.get('remote.<名字>')` 读：在根上下文里直接读 `remote` 的子属性，cordis 会以「没有 inject」拒绝。
  - 选中后走宿主自己的导航：`uiWorkspace.openSession` / `startSession`、`pluginNavigation.openBundle`、`layout.selectPanel`，Skill 经会话输入的 `setDraft` 与 `focus`；设置页与快捷键列表经它们在插槽登记里声明的 store（`slots.entries(key)` 条目上的 `store.create()`）打开。宿主的快捷键命令没有公开的执行入口，所以快捷键行打开快捷键列表，不代为执行命令。
  - 匹配沿用宿主侧栏搜索的子串规则，排序为标题开头、标题中的词开头、标题其他位置、第二个键（路径、包名、描述、别名）。宿主 `/` 菜单的子序列排序在标题、路径和描述上会命中大量无关结果，因此不用。
  - 消息内容由宿主半边的 `/session-search` 路由搜（host/search.js）。宿主自己的内容索引（`sessions.search`，SQLite FTS5）在出厂的 Web 组合里是关的（`openAt: never`）；就算打开，它的 `unicode61` 分词只按整词匹配，一串不带空格的中文是一个词，句子中间的几个字永远搜不到。路由改经宿主的 `sessionQuery.readSession` 读原始日志，只取用户与助手消息里的文本块（助手消息里的工具调用参数不算），匹配规则照搬 `sessionQuery` 自己 `text` 过滤的写法：字面、不分大小写、空白可伸缩。每个会话的消息文本连同变化标记留在内存里：存档用持久化后端 `list()` 给的修订号（JSONL 是文件 stat），宿主开着的会话用日志长度 `seq`；搜索时只重读标记变了的会话，子代理会话不读。面板打开时先发一次不带查询的请求，让宿主半边提前读完；每个会话只回最新的一处命中，附一行摘录与命中位置，面板把命中的字加粗。
- **理由**：面板要做的每件事宿主都已有一条路：模态层、列表数据、打开会话与页面。借用这些路，面板的行为与宿主各处一致，也不重复实现。内容搜索是唯一的例外：宿主的索引对中文不可用，读日志与匹配规则仍用宿主的，只把逐字匹配放在插件这边。
- **代价**：依赖上面这些服务、store 与品牌行结构的现状，宿主改名或收回时需要跟进；smoke 只覆盖放置、打开与卸载，数据与导航在真实页面核对。内容搜索在进程启动后的第一次要把全部会话读一遍（本机 128 个会话约 16 秒），之后每次查询约 0.1 秒；消息文本常驻宿主进程内存，大小约等于全部对话的文字量。
- **重审条件**：宿主提供自己的全局搜索或命令面板时，改为打开宿主的；宿主的内容索引能按子串匹配中文、并在出厂组合里打开时，内容搜索改回 `sessions.search`。

## D23. 轮次状态行：宿主的控件、宿主的数据

- **决定**：
  - 宿主把每一轮的「过程」控件（ui-chat 的 turn-process：进行中显示「深度求索中，用时 N 秒」，停止与失败后显示「已停止」「处理失败」）排在这一轮的最前面。进行中、已停止与失败的轮次里，这个控件改做状态行、排到这一轮的工作之后；正常结束的一轮保留宿主的控件与位置，它负责折叠这一轮的工作。
  - 皮肤不另建元素，也不移动节点：对话列本就是纵向 flex，刷新按文档顺序给列里的行写内联的 `--dsh-claude-turn-order`，样式表只对带这个属性的行取 `order: var(--dsh-claude-turn-order)`。第一个要移动的控件之前的行不写、保持 0；每个要移动的控件排在它这一轮最后一行（页脚 `turn-tail` 除外）之后，从那里起的行（页脚、后面的轮次、排队中的消息）逐级加一层，所以历史里多处停止或失败的轮次各自排对。标记与顺序每轮刷新重算，不再需要的当场撤掉。
  - 控件改画成 Claude Code 的状态行：火花标记加一行文字。进行中为「用时 · 输出 tokens · 当前动作」，火花转动；已停止或失败为「宿主的已停止 / 处理失败 · 用时 · 输出 tokens」，火花静止。状态写在按钮的 `data-dsh-claude-turn-state`（`live` / `stopped` / `failed`），文字写在 `data-dsh-claude-turn-status`，样式表用 `attr()` 画出来；宿主自己的标签（和它每秒跳动的时钟）留在 DOM 里、只是不显示，React 的节点不被改写。
  - 数字只读宿主的聊天快照：`uiConversation.binding(会话 id).target('chat')`，会话 id 取对话区外层的 `data-conversation-session`，轮次的状态与结束原因取快照里这一轮的 `status` 与 `turn/end` 的 `reason.kind`（`aborted` / `error`）。用时取这一轮 `turn/start` 到现在（进行中）或到 `turn/end`（已结束）；输出 tokens 是这一轮已经结束的各步上报的 `usage.outputTokens` 之和，正在输出的那一步结束后才计入，不做估算；当前动作按最新一步的助手输出判断（它在输出过程中原地增长）：最后一块是推理为「思考中」，推理之后开始输出正文或工具调用时为「思考了 N 秒」，没有推理时为「输出中」或「准备调用工具」；这一步没在输出时，这一轮有进行中的工具调用为「运行工具中」，否则为「等待模型」。快照不带推理的时间戳，「思考了」的时长是本页面看到推理出现到结束的时间。时长的写法（进行中秒数不补零，结束后补零）与「已停止」「处理失败」两个词用宿主 `chat` 文案表里的原文，与宿主自己的显示一致。
  - 状态行没有自己的计时器：宿主的时钟每秒改写一次标签文字，这次改动触发的刷新顺带更新状态行。
- **理由**：状态行在工作过程的末尾，正在发生的事、停在哪里、为何失败都与它的状态挨在一起，和 Claude Code 一样；借用宿主的控件与数据，完成、停止、失败与折叠都还是宿主的行为。
- **代价**：依赖对话列是纵向 flex、行上的 `data-chat-flow-kind` / `data-chat-turn` 与聊天快照的现状；读屏与 Tab 键仍按文档顺序先经过这个控件；历史里有停止或失败的轮次时，它之后的每一行都带一个内联顺序值。
- **重审条件**：宿主提供可替换的轮次状态插槽，或把这个控件移到这一轮末尾时，改用宿主的。
