DEVELOPMENT.md

August 18, 2026 · View on GitHub

本文件浓缩 2026-08-16(M1–M4 数据链路)+ 2026-08-17(真左栏 feature/left-column)+ 2026-08-17(左栏交互 feature/left-column-interactions,已 no-ff 合并回 main)+ 2026-08-17(半圆按钮 feature/expand-button-shape,已 no-ff 合并回 main)+ 2026-08-17(交接核查:回退破坏性分支 + 补全官方层叠/结构调研)+ 2026-08-18(host 缓存补齐 feature/host-backfill:启动后台顺序冷读补齐缺 history 的旧会话)+ 2026-08-18(移除历史索引 tab feature/remove-history-tab:conversation.view 注册删除,保留 shell.overlay 左栏)+ 2026-08-18(交接同步:两轮功能 GUI 实测通过 + git 分支状态刷新 + 本轮新验证的官方机制补充)+ 2026-08-18(安装方式 bundle 化 feature/plugin-add-bundle:dsh plugin add 可装 + web profile 迁移重启)+ 2026-08-18(README 改用户向 feature/user-readme:开发内容迁入 §8,README 只留安装/使用/配置/FAQ)+ 八轮开发对话的后续开发所需信息。 代码变更历史见 git log(b32e2ba 起,里程碑:skeleton → M1–M4 → 左栏 spike/拖宽/跳转/渲染协调 → 分叉交互 → 行精简 → 分支列表 → 背景统一 → 跳转指示 → header 对齐 → 折叠按钮 → 半圆按钮 → host 缓存补齐 → 移除历史索引 tab)。 详细设计见 DESIGN.md(Session Tree / History Index 插件)。

1. 当前状态

  • 已完成:骨架、挂载验证、M1–M4、真左栏feature/left-column 已合并回 main:shell.overlay 浮动列 + 内容让位 + 节点列表 + 折叠竖条(☰历史,可发现性)+ 拖拽调宽/记忆(240–480、聊天保 480、双击复位 280、localStorage)+ 点击行内跳转(含 loadOlder 分页兜底)+ 渲染协调修复),81 测试全绿;feature/left-column-interactions(已 no-ff 合并回 main,8 个提交,91 测试全绿):
    • 行尾「续写」按钮 hover 显现 + fork/open(sessions.forkopen(childId));
    • 分叉交互重构——行首分叉数字(hover/展开变官方 chevron)点击展开;展开体为行下方 column 兄弟(复刻官方 DisclosureRow 骨架,不参与行内 flex、行高恒定,marginLeft 20 缩进无线框);
    • 行精简——每节点单行标题(删 meta 与 kind emoji),行 hover 高亮(interactive-bg-hover),续写按钮改官方 pill 风格(radius 999px);
    • 分支列表同款风格——展开体内分支行与节点行同款单行样式 + hover 高亮,只显示叶子摘要(fork 标题多为「旧标题+数字后缀」无辨识度),无「切换」按钮、整行点击直接 sessions.open 跳转;
    • 跳转缓冲指示——点击节点跳转(尤其 loadOlder 翻页耗时)时,被点击行行尾显示官方 IconLoadingOutline16 旋转圆环(注入 style 标签定义 @keyframes),跳转结束/失败自动清除,jumpGenRef 世代守卫保证只由最新跳转清除;
    • 背景统一——左栏 panel 与 tab 面板 bg-layer-1bg-base(官方主表面惯例),展开体去线框(缩进即区分);
    • 标题区分隔线对齐官方会话 header——左栏标题区复刻官方两段式(titleRow 32 + 类 tabs 行 27,节点计数占位),分隔线落 75px 与右侧对话区平齐(box-sizing 坑见 §5);
    • 折叠按钮重构——移除 28px 竖向 rail,折叠态面板 0 宽,☰ 展开按钮作为 Fragment 兄弟悬浮,与 header「«」关于分割线镜像对称(见 §2)。
    • 半圆按钮重构(feature/expand-button-shape)——折叠按钮 <-左半圆、展开按钮 ->右半圆:两半圆同半径 r=10(20×20)、gap 0 紧贴各自边缘——折叠态右半圆直径边贴对话区内容左缘(恰好落在官方 header 左 padding 20px 区内,不压标题)、展开态左半圆直径边贴面板右缘(header 右 padding 0)——直径边都对着内容左缘、弧朝外,视觉拼成一个整圆;垂直中心 = titleRow 中心 y=28(translateY(-50%) 精确定心);hover 高亮勾勒半圆 + title 描述;两按钮互斥出现,共用 railHovered(见 §2 决策表)。GUI 实测通过(用户确认无问题),已 no-ff 合并回 main(7daec7d + merge 544056d),91 测试全绿。
    • host 缓存补齐(feature/host-backfill,已 no-ff 合并回 main fdaae76)——启动后台顺序冷读补齐缺 history 投影缓存的旧会话(src/backfill.ts 纯编排 + src/index.ts ctx.effect 接线,幂等/可中断/每会话错误隔离,见 §2 决策行与 §7.0);重启 GUI 后 GUI 实测通过(用户确认:打开旧对话稍等片刻即显示逻辑节点,功能无问题),102 测试全绿。
    • 移除历史索引 tab(feature/remove-history-tab,已 no-ff 合并回 main 480db5a)——删除 conversation.view 注册与 createHistoryView 整块(含 VIEW_ID/HistoryViewProps/KIND_ICONS),保留左栏共享代码(toLineageSessions/接口/history/*/jump/left-column)与 shell.overlay 注册;官方 view 环剩 chat/trajectory 两项 → tabs 行照常 → 左栏 75px header 对齐不受影响;GUI 实测通过(用户确认无问题),98 测试全绿(102 − 4 个 tab 用例);client 改动刷新即生效。
    • 能力必选化(feature/required-capabilities,已 no-ff 合并回 main 146811f)——把四个能力从「可选(ctx.get 缺席静默跳过)」改为「必选(cordis fiber inject,缺席则 fiber 保持 PENDING、DSH boot 失败 loud)」。host inject = ['sessionProjections', 'sessionProjectionCache']src/index.ts 具名导出,apply 内直读 ctx.sessionProjections / ctx.sessionProjectionCache,删除提前 return;sessionPersistence 保持可选 ctx.get);client factory 返回对象 inject = ['slots', 'sessions']apply 内直读 ctx.slots / ctx.sessionstimer 保持可选 ctx.get);package.json dsh.client.inject 同步为 ["slots","sessions"](信息性 wire 依赖边,运行时权威是 factory 返回对象的 inject)。行为变化:无这些服务的组装(如 headless)里插件从静默 no-op 变为 PENDING;trail-test smoke profile 需补装 sessionProjectionCache 及其 storage 栈(scripts/smoke.sh 已加,见 §4)。99 测试全绿。
    • 跳转扩窗重写:连点 loadOlder 直到目标可见(feature/required-capabilities,已 no-ff 合并回 main 146811f)——修「刷新后点旧节点 4s 后报『聊天视图未激活』」+ 按用户定调改为「不可见就上滑直至可见,不直接失败」:
      • 语义:目标在窗口且可见 → 直接滚;不在窗口 → 循环 session.loadOlder()(等价官方「加载更早」连点)直到目标可见行出现;翻到 !hasMore 才失败(窗口即日志起点,不因页数小放弃);hidden / 已压缩内容不在扩窗语义内。
      • src/jump.tsmatchTarget(同 turn 可见最小 anchorSeq 精确匹配,turn<0 跳过)、resolveFallback(精确 key 命中但行不渲染时:同 turn 次小可见 → ≥startSeq 最近可见 → 全局最近可见,排除 excludeKey)、jumpFailureMessage(失败码文案映射:VIEW_INACTIVE / TARGET_HIDDEN(±fallback) / NOT_FOUND / TIMEOUT)、minAnchorSeq(loadOlder 无进展防空转)。
      • src/client.tsreadCandidates 只取 visibility !== 'hidden'(与官方 chat.orderorderedVisible 同口径);扩窗循环终止条件 = 命中 / !hasMore / JUMP_MAX_PAGES=100(≈5000 条)/ 总超时 JUMP_TOTAL_TIMEOUT_MS=15000 / gen 守卫(新跳转接管)/ [data-chat-flow] 消失(VIEW_INACTIVE);openState === 'loading'(刷新水合中)等待不误报;行等待 JUMP_ROW_WAIT_MS=8000,超时后走 resolveFallback 邻近可见回退(成功提示「目标无独立气泡,已定位到邻近内容」)。
      • 已知边界(官方能力硬限制):hidden-only turn 无 DOM 行(只能邻近回退);compacted turn 内容已被官方 surface replace 删除(当前 66 会话实测 0 个含 compaction 事件,低频;NOT_FOUND 文案带「可能已压缩」提示,真实出现再补 fold 折叠)。官方无 O(1) 跳窗 API(session.history 仅 beforeSeq、installWindow 私有),超深历史逐页翻慢但可达。
      • 109 测试全绿。GUI 实测通过(用户确认无问题):刷新后点旧节点不再 4s 报「聊天视图未激活」,自动翻页直至目标可见。
    • 安装方式 bundle 化(feature/plugin-add-bundle,当前分支,未合并,提交 479378c)——安装形态从「示例 cordis.yml + 手工 patch」改为 dsh plugin add 可装的 bundle
      • 包名去 scope:@deepseek-ai/dsh-trail-plugindsh-trail-plugin(裸名,与内部插件名 src/index.tsexport const name 一致;npm 上可用,dsh-trail 裸名被他人占用不可用);
      • package.jsondsh.bundle.patch(→ ./cordis.patch.yml)+ files 携带;根目录 cordis.yml 改名 cordis.patch.yml(git mv 保留历史),内容改 - insert: 包名行;
      • 安装后 dsh 自动 reconcile 进 profile 的 dsh.profile.bundles,boot 时 bundle 层插入组合行——不再手工写 profile 的 cordis.patch.yml(机制细节/失败模式见 §3 挂载条目、§5 踩坑);
      • scripts/smoke.sh:删手工插件行(防 bundle 层 + 用户层同 id 重复)、加旧包名依赖清理 + --dump-config 恰一行断言、DSH_BIN 默认 pnpm --dir /app dshtests/plugin-shape.test.ts 改 bundle 形态断言(insert 行 name === package.json#name、dsh.bundle 与 files 声明一致);
      • web profile(正式 GUI)已迁移并重启验证通过bundles = [dsh-base, dsh-web-app, dsh-notification, dsh-workbench-plugin, dshmarket, dsh-trail-plugin]、deps 无旧包名键、cordis.patch.yml 为合法空 [];浏览器 roster 出现 dsh-trail-plugin/client.js?rev=…(HTTP 200),session.list 67 会话 62 带 history 投影(5 个缺失均为只有 session.jsonl.zstd 压缩日志的旧会话,backfill 不覆盖——既有数据条件,非回归,见 §5);
      • 110 测试全绿,trail-test smoke 端到端通过(bundle 安装 → reconcile → dump-config 单行 → hello world 日志)。
  • git/分支状态main 已含全部已合并特性(最新 merge 146811f:required-capabilities 能力必选化 + 跳转扩窗重写);origin/main 停留在 7a12515(本地领先、未 push)。历史 feature 分支保留:feature/expand-button-shape / feature/left-column / feature/left-column-interactions / feature/host-backfill / feature/remove-history-tab(均已 no-ff 合并回 main)、feature/plugin-skeleton(历史)。feat/plugin-add-bundle = 当前分支(领先 main 1 个提交 479378c 未 push/未合并:bundle 化安装,110 测试全绿、smoke 通过、web profile 迁移 GUI 实测通过)。feature/narrow-auto-collapse = 破坏性分支(上次开发破坏了左栏功能后被放弃回退,勿在其上继续开发,保留仅作参考)。开发约定:中文 commit + scope(feat/fix/style/docs(left-column): …,host 侧 feat(host),打包/安装侧 feat(plugin-bundle))、特性合并回 main 一律 no-ff、feature 分支合并不删。
  • 待办:① host 侧补齐缺 history 的投影缓存 — ✅ 已实现并 GUI 实测通过(src/backfill.ts 启动后台顺序冷读补齐 + src/index.ts 接线,幂等/可中断;已 no-ff 合并回 main fdaae76);③ 旧 tab 去留 — ✅ 已移除(feature/remove-history-tab:删除 conversation.view 注册与 createHistoryView 整块,保留左栏共享的 toLineageSessions/接口/history/*client 改动刷新即生效无需重启);② 左栏交互补全剩 窄屏自动折叠(阈值触发,拖拽钳制已就位)+ 跳转高亮 polish;④ M5 二级完整路径。
  • 验证约定:client bundle 的 rev = 文件 sha1 前 12 位;实测 web 服务器按请求实时计算 manifestpnpm build 后浏览器刷新即可见,无需重启 GUI——旧记录"重启才进 boot manifest"已过时)。注意 curl 首查可能命中 index.html 缓存返回旧 rev,加 cache-buster(?cb=$(date +%s%N))再查。host 侧(src/index.ts)改动仍需重启 GUI 生效。
  • 环境:DSH 源码在 /app(只读参考,禁止修改);DSH_HOME=/data/dsh-home;GUI 在 127.0.0.1:3080;dsh CLI 在容器内只能以源码方式运行:pnpm --dir /app dsh <args>(等价 node --import tsx/esm apps/cli/src/bin.ts)。

2. 架构决策(含理由,勿轻易推翻)

决策理由
投影缓存承载节点树(非自建存储)事件驱动折叠、checkpoint、冷读、schema 校验、生命周期、client useProjection 通道全部官方托管;落盘 $DSH_HOME/storages/session_projcache.json(该文件已在跑 14 个投影 key,28 会话约 127KB);写合并 200 事件/5s + turn/end + 会话关闭;stateVersion 不匹配自动重算。host 零改动约束:一切派生数据只从官方已下发数据计算
fork 边界 = turn/end 事件 seqsessions.fork 校验 boundary 必须是连续事件 seq 且前缀不能停在未闭合 turn(OPEN_TURN 报错)——turn/end seq 天然安全
nodeKey = (rootId, boundarySeq)结构身份:fork 深拷贝保留事件 seq,同一逻辑节点在整棵 fork 树内 seq 唯一且位置对齐;rootId(沿官方 parentId 上溯)消除无关树之间的 seq 命名空间碰撞。匹配键必须用结构身份,不能用内容(内容相同是巧合信号,会假阳性)
角标 = 共享该逻辑节点的全部会话(排除自身)用户明确口径:如 A→B→C→D / A→B→F / A→B→C→G 中,会话 1 的节点 B 应显示分叉 2(会话 0 与 2),祖先/兄弟/后代都计入。节点中心索引桶成员即全部共享会话
挂载在 conversation.view(tab)→ 真左栏挂 shell.overlay早期因"替换 session 体要继承草稿镜像 + 视图环职责、tab 选中态在内部 chatStore"搁置左栏;2026-08-17 研究确认槽位机制硬墙:子槽位声明排他(重复声明 register throw)+ chatStore/views 账本私有 + 无 renderSlot 授权 = 替换 = 重写聊天渲染。改用 shell.overlay 浮动列(list 槽、replaceRisk none、唯一可覆盖会话列的可加性座位),内容让位 = 会话列根元素 padding-left;tab 保留作对比,稳定后移除已移除:feature/remove-history-tab 删除 conversation.view 注册;官方 view 环仍剩 chat order0 + trajectory order10 两项 → tabs 行照常显示 → 左栏 header 75px 分隔线对齐不受影响)
行内跳转走官方 DOM 锚点(左栏后续迭代)聊天行自带 data-chat-anchor-key(= 会话快照节点 key),滚动容器 [data-conversation-scroll];历史节点 → 聊天节点映射用 ctx.sessions.binding(id).session(ObservableSnapshot<ConversationSnapshot>)按 turn/anchorSeq 对齐
左栏几何必须实时查询节点(渲染协调)conversation 槽位是 session-maybe:会话切换时内容按 epoch 重挂载(DOM 节点被替换)。若 layout effect 闭包缓存 convRoot/panel 引用 → 切换后指向 detached 节点 → RO 永不触发、getBoundingClientRect 全 0 → 面板钉死 (0,0)/0 高(表现:切走切回左栏消失、关侧栏竖条不回位)。必须:effect deps 含 current(切换即重跑)、每次 applyLayout/漂移轮询实时 closest/querySelector、cleanup 实时清理
历史投影对"注册前已沉睡"的旧会话缺失history 投影 2026-08-16 注册;此前存在且之后从未打开的会话,checkpoint 从未写 history 缓存行 → 列表行投影无 history(实测 45 会话仅 20 有)。会话打开会走 coldSnapshot(缓存行+尾部重放)补齐并写回。列表行投影来源:live 会话 = sessionProjections.snapshot(session)(实时),cold 会话 = sessionProjectionCache.cachedSnapshot(meta)(只读缓存行)
启动后台补齐缺失 history 缓存(顺序、幂等、自愈)官方冷读阶梯即补齐路径:cachedSnapshot(meta) 判定(values.history 缺失 = 无可用行/version 不匹配/缓存读抛错,一律按缺失),coldSnapshot(id, signal) 补齐(缓存行+尾部重放→重折叠→fail-soft 写回)——与「会话打开时自动补齐」同一机制,只是批量提前到启动时。顺序执行(27 会话毫秒级)、每会话错误隔离、ctx.effect + AbortSignal(插件停止/更新即中断循环,abort 导致的失败不计 skipped)。只处理当前缺失:空 log 会话补齐 init 空态行后 cachedSnapshot 有值、不再重复。不加 config 开关(幂等、开销可忽略、自愈未来任何新缺失)。src/backfill.ts 纯编排(最小本地接口,不 import dsh 包),src/index.ts 在投影注册后接线,sessionPersistence 缺席(headless)跳过补齐
四个能力必选(fiber inject)本插件功能本质依赖:host 的 sessionProjections(投影注册表)/ sessionProjectionCache(缓存)+ client 的 slots(槽位注册)/ sessions(fork/open/跳转)。ctx.get 缺席静默跳过会掩盖装配错误(注册了但没生效、无从报错);改 cordis inject 声明后 fiber 进入 PENDING、DSH boot 的 assertEntriesActivated 失败 loudpending (waiting for service: …)),装配缺失在启动时暴露而非运行时静默。官方同款用法:host web-app export const inject = ['webServer']client-modules static inject = ['webServer','loader']、client app-shell inject = ['slots','sessions','layout'] 与所有 ui-* 插件。注意:client 侧本插件 bundle(esbuild factory handoff)与官方 tsdown 包不同——官方 bundle 把整个 CJS 命名空间(含顶层 export const inject)交给 loader,本插件 factory 只返回插件对象,所以 inject 必须挂在 factory 返回对象上{name, inject, apply}),顶层具名导出不会到达 loader。sessionPersistence / timer 维持可选(ctx.get),不在必选列表
跳转扩窗 = 连点 loadOlder 直到目标可见(不直接失败)用户定调「不可见就上滑直至可见」:目标不在当前窗口时循环 session.loadOlder()(等价官方「加载更早」连点)把更早历史 prepend 进窗口,每页用 matchTarget 精确匹配(只考虑 visible 行);翻到 !hasMore 才失败(窗口即日志起点,是完备终止条件,不存在无限循环)。hidden 与已压缩内容不在扩窗语义内——ChatView 只渲染 chat.orderorderedVisible 过滤),hidden 节点翻遍全部历史也没有 DOM 行,所以匹配只看 visible、命中后行不渲染才走 resolveFallback 邻近可见回退(或准确失败),绝不空翻页。失败码分类(jumpFailureMessage):VIEW_INACTIVE(视图没了)/ TARGET_HIDDEN(hidden 且±fallback)/ NOT_FOUND(!hasMore 仍无,可能已压缩)/ TIMEOUT(100 页或 15s 保护)。防御:minAnchorSeq 检测 loadOlder 无进展(守卫空转)即终止;gen 守卫让新跳转接管旧跳转;openState==='loading'(刷新水合中)等待不误报。曾尝试「四级回退直接落邻近行」被用户否决(要精确目标优先、翻到底再说)——回退只在精确 key 命中但行不渲染时启用
client 半区用 esbuild 打自定义 loader bundleDSH 静态插件 client 包必须产出 window.__ModuleLoader__.load({id, factory(require)});esbuild 内联所有源码模块,external 只留平台模块(react、@deepseek-ai/cordis、@deepseek-ai/dsh-client-ui-primitives 等)由浏览器模块表解析(清单见 /app/packages/client/web/src/platform.ts 的 PLATFORM_MODULES)。zod 只在 host 侧src/history/schema.ts),client 严禁 import(会打进 bundle)。官方模块复用走 require('@deepseek-ai/dsh-client-ui-primitives') + 本地最小类型(本地 node_modules 无此包,import 语句会让 tsc 解析失败)
分叉展开结构 = 行下方 column 兄弟(对齐官方 DisclosureRow)展开体若作行内 flex item 会被横向挤压、撑高整行(曾现 bug:标题被挤占、胶囊被挤到中间)。官方 DisclosureRow 骨架:root column = [行, 展开体],展开体是行下方兄弟、不参与行内 flex、行高恒定。不复用 DisclosureRow 组件本体:① 行点击模型冲突(官方整行点击=展开,我们=跳转,且其无行点击自定义入口);② 官方 .row 固定 24px 行高(CSS module 无覆盖入口),放不下我们的摘要+meta 两行。复用官方 chevron 元素IconChevronDownOutline14,模块表 external),hover/展开时行首数字变 chevron(v 型提示),交互语义与官方 tool 行一致
主表面一律 bg-base(官方惯例)官方大面积表面全部 bg-base:会话区 ConversationRoot / DetailsPanel / AppFrame / QueueDock / ReasoningRow / GenericCommandCard;bg-layer-1/2 只用于 trajectory 表格树 / JsonTree / settings 卡片等深嵌套表面(Modal/HoverCard 用专用 token + 阴影)。左栏 panel 与 tab 面板 bg-layer-1 → bg-base;展开体同底靠边框/缩进区分层级(官方 ioCard 模式),再简化为纯缩进(去线框)
左栏标题区分隔线 = 官方会话 header 分隔线(75px)官方 header(ConversationRoot.module.css):padding-top 12 + titleRow min-height 32 + tabs 行(margin-top 4 + tab 高 27 = line-height 16 + padding-bottom 11);view 环 ≥2 项显示 tabs(chat order0 / trajectory order10 / 我们的 history order20 → 移除 tab 后当前 2 项:chat + trajectory,tabs 行仍显示)。分隔线在 12+32+4+27 = 75px 处。左栏无 tabs,复刻两段式:titleRow(32) + 类 tabs 行(「N 个逻辑节点」占位,样式对齐官方 .tab:13px/16 + label-tertiary),minHeight 75(border-box)使线平齐。官方布局数值变动需同步此值
半圆按钮 = 折叠 <- 左半圆 / 展开 -> 右半圆(直径边都贴内容左缘,拼成一个整圆)自 2026-08-17 取代 §41 的裸字符按钮:形状用纯 CSS border-radius(左半圆 r 0 0 r / 右半圆 0 r r r,元素 2r×2r),官方 primitives 模块表无 hamburger/箭头图标(只有 check/chevron/close/copy/warning),自绘是唯一轻量路线。几何常量 EXPAND_BUTTON_RADIUS=10(20×20,折叠态右半圆恰好落在官方 header 左 padding 20px 区内不压标题)、EXPAND_BUTTON_CENTER_Y=28(titleRow 中心 = padding-top 12 + 32/2)、EXPAND_BUTTON_DIVIDER_GAP=0(直径边紧贴边缘:折叠态贴对话区内容左缘、展开态贴面板右缘 = header 右 padding 0)。锚点语义:折叠分支 left = convRect.left − frameRect.left不再加记忆宽度——旧版锚定"虚拟分割线"导致按钮悬在对话区中间);垂直定心 = top: CENTER_Y + transform: translateY(-50%)。展开态折叠按钮在 header titleRow 内(flex 流,天然跟随面板右缘/拖拽),zIndex: 3 盖过拖拽手柄(zIndex 2,面板右缘 8px 命中条)——否则按钮右半被手柄抢占点击。两按钮互斥出现、共用 railHovered hover 态(半圆 hover 高亮 + title 描述「折叠左栏」/「展开历史索引」)
官方 primitives 复用面@deepseek-ai/dsh-client-ui-primitives(浏览器模块表 external)复用:IconChevronDownOutline14(分叉 chevron)、IconLoadingOutline16(跳转缓冲圆环)。均通过 require('...') + 本地最小 ClientPrimitives 接口(本地 node_modules 无此包)。无 CSS 基建:旋转动画用注入 <style> 标签定义 @keyframes(SPIN_CSS,静态注入一次)
不用 host.call / harness.handle那是动态插件专用 RPC(静态 bundle 无此通道);静态插件跨平面数据走 client 投影 / Remote($mount + typert 生成产物,较重,已避免)
安装 = bundle(dsh.bundle.patch + cordis.patch.ymldsh plugin --profile <name> add <spec> 后包自动进 profile 的 dsh.profile.bundles(reconcile 检查每个已装依赖是否声明 dsh.bundle),boot 时 bundle 层把组合行插进树——用户层(profile 的 cordis.patch.yml)只放覆盖/禁用,不再手写插件行(bundle 层 + 用户层同 id 各插一次 → duplicate loader entry id 启动失败)。行 name 必须是包名(Node 从 profile 目录 node_modules 解析),与 package.json#name 一致。storage 栈等公共行绝不进本插件的 bundle patch(web profile 的 web-app bundle 已提供同 id 行,重复 insert 即 duplicate;trail-test 由 smoke.sh 在用户层补)。改包名后旧依赖键必须 plugin remove 清掉——旧键同样声明 dsh.bundle 会和新键双份进 bundles 层(同一包目录插同一 id 两次)。包名决策:dsh-trail-plugin(npm 可用;dsh-trail 裸名被他人占用)

3. 已验证的官方机制(事实清单)

  • 投影ctx.sessionProjections.register({key, schema, init, apply, view, stateVersion})——apply 纯同步增量折叠,无关事件必须返回同一 state 引用;state 须 plain JSON;注册是 effect,卸载即消失。sessionProjectionCachesession_projcache 域;冷读阶梯 cachedSnapshot(零 I/O) → coldSnapshot(缓存行+尾部重放)。
  • client 读取:会话列表 useSessions 每行带 parentId(= header.parentSession,fork 父)与 projectionValues.history(该会话节点树)——wire 上 projections.values 是 z.record(string, unknown) 开放 map,外部包新 key 原样通过。这是 M4 纯 client 谱系的数据基础。useProjection('history')(标准 props;undefined = 能力缺失) —— 会话作用域投影通道已随 tab 移除(feature/remove-history-tab 后本插件不再注册 conversation.view,无需 useProjection);左栏数据全部走列表行投影 projectionValues.history
  • fork:client sessions.fork({sessionId, atSeq, increaseTitle}) → host sessions.fork,boundary=seq,seed=前缀拷贝;sessions.open(childId) 切换。
  • 槽位conversation.view 是 list、可添加(replaceRisk none),注册 {name, id, order, label} + 组件;标准 props 含 useSessions/useProjection/sessionId。client slots 服务经 inject 必选后直读 ctx.slots(slots.inject + slots.register;官方 ui-* 插件同款)。本插件已不再注册 conversation.view(tab 已移除),只注册 shell.overlay(id dsh-trail-left-column,order 10)。
  • 缓存冷读阶梯(host 补齐用,本轮实测确认)sessionPersistence.list(signal?)SessionHeader[](轻量 meta 列举,不解析 log);sessionProjectionCache.cachedSnapshot(meta) 同步零 I/O(identity 校验 + viewCheckpoint 只服务 version 匹配的 key);coldSnapshot(id, signal?) 异步冷读(缓存行 + readFrom 尾部重放 → registry restore 重折叠 → putSoft fail-soft 写回);restoreFloor 算出重放起点——version 不匹配/超界的行被丢弃,若 floor>0 则整段从 seq0 重读(coldSnapshot 内部已处理)。sessionProjectionCache 服务 requireTable()[Service.init] 完成后才可注入——ctx.get 拿到即已就绪。
  • ctx.effect 语义(本轮接线与测试确认):cordis ctx.effect(fn) 立即执行 setup、返回其 cleanup(插件停止/更新时调用)。测试 fakeCtx 的 effect 桩必须模拟立即调用 + 记录 cleanup,才能测到「注册即触发」的异步行为(如 backfill 启动)。
  • 挂载(bundle 形态)dsh plugin --profile <name> add <路径>(pnpm link 进 profile;容器内用 pnpm --dir /app dsh plugin ...)。包声明 dsh.bundle.patch(→ ./cordis.patch.yml,顶层是 patch 数组、- insert: 内行 name = 包名)即自动进 profile 的 dsh.profile.bundles,boot 时 bundle 层自动插入组合行——不再手工写 profile 的 cordis.patch.yml(那里只放用户层覆盖;bundle 行 + 用户层同 id 重复插会 duplicate loader entry id)。改包名后旧依赖键要 plugin remove 清掉,否则旧键同样声明 dsh.bundle、双份进 bundles 层。Loader 以 profile 目录为 baseUrl;client 半区经 package.json dsh.client {platform:'web'} + exports["./client"] 进浏览器 roster,URL /plugins/dsh-trail-plugin/client.js?rev=…验证dsh --profile <name> --dump-config(bundle 层带 # == <包名> 注释,用户层标路径)应恰见一行 id: dsh-trail-plugin失败模式:patch 文件缺失/非法(非顶层数组)→ boot/dump 失败 loudloadOverlayPatches 对声明的 patch 路径缺失 throw、「must be a top-level YAML array」);reconcile 在每次成功dsh plugin 运行后执行——已装依赖补上 dsh.bundle 声明后,任意一次 plugin add/remove/install 都会把它补进 bundles,无需重新 add。官方文档:/app/docs/user/develop/basic/publish.md(bundle/profile 两概念、加载顺序、git 安装的 prepare/allowBuilds 坑)。
  • client bundle 契约window.__ModuleLoader__.load({id: 包名, factory(require)});factory 返回 {name, apply};模块表含 react / @deepseek-ai/cordis / @deepseek-ai/dsh-client-ui-slots 等。
  • 会话快照访问(跳转/分页用)ctx.sessions.binding(id)?.session = SessionFace = ISession & ObservableSnapshot<ConversationSnapshot>loadOlder()ISession 动词(无需 scope().get('conversation'));每页 PAGE_MESSAGES=50 条,守卫 openState==='open' && hasMore && !loadingOlder(不满足时静默 no-op),prepend 后快照同步更新。快照结构:chat.nodes(ChatNodeStore:get(key)/values())、chat.orderhasMoreloadingOlderopenState;聊天节点 location = {kind:'turn'|'step', turn: TurnLocation}(turn 号与历史节点对齐,fork 前缀拷贝保留)。
  • DOM 锚点(跳转落点):聊天行 data-chat-anchor-key(= 快照节点 key,ChatView 内部滚动恢复也用同一属性);[data-chat-flow] = 聊天视图容器(判断视图是否挂载);滚动口 [data-conversation-scroll](scrollBody)。scrollIntoView({block:'start'}) 即滚到官方滚动口。
  • 列表行投影管线(client)session.list 响应行带 projections(host 组装:live 用 sessionProjections.snapshot(),cold 用 sessionProjectionCache.cachedSnapshot(),失败/空则整块缺席);client manager 逐 key store.apply(key, value, asOfSeq) 进 per-session projectionStores(higher-seq-wins),列表行 projectionValues = store.values()。列表行投影 ≠ 会话作用域 useProjection(后者是 per-session 完整投影通道)。
  • current 的 masked gapprojectList 中 selected 会话暂不在 items(如切换间隙)→ current=undefined(UI 呈 hero 态、左栏隐藏),会话回列表后自动回填——是官方瞬态,非 bug。
  • useSessions 底层useSyncExternalStoreWithSelectorpackages/client/web-react/src/bind.ts),默认 Object.is 相等;root scope 同样有 useSessions 且 state 含 current(SessionListState)。
  • shell.overlay 层叠结构(AppFrame.module.css,半圆按钮调研结论).overlayLayer { position:absolute; inset:0; z-index:20; pointer-events:none } + .overlayLayer > * { pointer-events:auto };对话区列 .centerCol z-index auto → 对话区整体(含其内部 z-index 1/7/8/100 的 sticky 元素)都在 overlayLayer(z-20) 之下——任何对话区重绘都不可能盖住 overlay 内元素(若看到"按钮被对话区覆盖",先查按钮是否真的在 overlayLayer 内/是否渲染)。frame 有 overflow:hidden(overlay 内元素超出 frame 会被裁剪)。
  • slot outlet 锚点容器:每个槽位渲染包 <div data-slot="<key>">display: contents(web-react scoped-slots.tsx ANCHOR_STYLE)——布局中性、不产生盒子,absolute 子元素的包含块上溯到 positioned 祖先(shell.overlay 即 overlayLayer),frame 坐标系成立;display: contents 不拦截指针事件。
  • 官方对话区 header 结构(ConversationRoot.module.css)header { padding: 12px 28px 0 20px };titleRow min-height 32(垂直范围 [12,44],中心 y=28);titleCluster flex:1 从内容左缘 +20px 开始——左 padding 20px 是空留白,可安全叠加覆盖元素(半圆按钮折叠态即嵌此区);header::after 分隔线 z-index 0 pointer-events none。
  • 官方 primitives 图标全清单IconCheckOutline16 / IconChevronDownOutline14 / IconCloseOutline16 / IconCopyOutline16 / IconWarningOutline16packages/client/ui-primitives/src/)——没有 hamburger/panel/箭头图标,左栏按钮类图标必须自绘(半圆按钮用 CSS border-radius 自绘即因此)。
  • chat.nodes ⊇ chat.order(跳转匹配必须过滤 hidden):ChatView 只渲染 chat.order,而 order = orderedVisible(nodes) = nodes.filter(n => n.visibility === 'visible').sort(anchorSeq)ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts:135);chat.nodes.values() 含全部节点(含 hidden)。hidden 节点没有对应 DOM 行——插件候选列表必须过滤 visibility !== 'hidden',否则命中 hidden key 会落进「行永不渲染」假失败(此前「刷新后点旧节点 4s 报错」根因)。
  • hidden 节点官方生产者(仅两处):仅 tool-call 无可见文本的 assistant(assistant.ts:306 visibility: 'hidden')、被 retry 取代的 turn-error(turn-error.ts:103)。另有合成 anchorSeq 偏移 CHAT_SYNTHETIC_SEQ_OFFSETS(interruptedAssistant -0.9 / interruptedFollowup -0.8 / maxTokensNotice 0.05 / finalizedFollowup 0.1,conversation-nodes/common.ts:13)。
  • [data-chat-flow] = ChatView 挂载标记(视图激活判定用它,勿用 [data-conversation-scroll]data-chat-flow 在 ChatView 的 column 上(ChatView.tsx:368),仅聊天视图挂载时存在(切 trajectory / hero / 会话未绑定时没有);[data-conversation-scroll] 在 ConversationRoot 上无条件渲染(hero 态也在),用它判激活会误判。data-chat-anchor-keydata-chat-flow-key 同值同元素(ChatNodeSeat.tsx:44-46),跳转只需认 anchor。
  • 官方无 O(1) 跳窗 API(深历史只能逐页)session.history 请求只有 beforeSeq/maxMessageshost/apiproxy/src/api/sessions.schema.ts:141,无 afterSeq);installWindow/replaceWindow/api/private history 全是 Session 私有成员(client/runtime/src/client/sessions/session.ts);PAGE_MESSAGES=50 是 client 内部常量。禁止自造 history RPC 或触碰私有窗口替换——逐页 loadOlder() 是唯一官方扩窗通道(守卫 openState==='open' && hasMore && !loadingOlder)。
  • compaction 是 surface replace(被压缩 turn 官方不保留)core/session/src/index.ts:713——compaction 从 log 删除被压缩事件(替换为 compaction/startcompaction/summary → 带 surfaceOp:{op:'replace'} 的替换 user/message → compaction/endcompaction/compaction/src/types.ts:23-91);compaction/summary 携带 shadowedRange:{start,end}/shadowedSeqs/summary 文本;chat 侧压缩区显示 compaction card,任何 UI(含官方)都看不到原事件。host history 分页按消息边界对齐,compaction 记录与替换消息同页(api/sessions.ts:268)。当前 66 会话实测 0 个含 compaction 事件(低频边界)。
  • 组合行行级 inject 也可声明必选服务:组合行(cordis.patch.yml / bundle 插入的行)可带 inject: 字段(EntryOptions.injectvendor/loader/src/config/entry.ts:21),loader 在 internal/pluginInject.resolve(fiber.entry.options.inject, fiber.inject) 合并进 fiber(vendor/loader/src/index.ts:122)——与模块级 export const inject 等效(都进同一个 fiber inject map),两种声明方式可二选一。
  • 会话窗口刷新后重置为 tail 页:刷新后 session.open() 只拉尾页(doOpeninstallWindow),会话窗口是内存态(chatScrollPositions/conversation store 不持久化)——旧节点跳转必然走翻页路径;dsh.sessions.current(localStorage)持久化上次选中会话并自动重开(sessions/service.ts:287)。

4. 验证配方

pnpm verify                        # typecheck + vitest + build(build 含 esbuild 打 client bundle)
# 起 trail-test profile 断言 hello world。注意:能力必选化后(feature/required-capabilities),
# trail-test 必须同装 sessionProjectionCache 及其 storage 栈(storage/storage-json/storage-domain,
# 配置与 web-app bundle 一致)——scripts/smoke.sh 已内置;缺它会 boot 失败 loud
# `dsh-trail-plugin: pending (waiting for service: sessionProjectionCache)`。
./scripts/smoke.sh
# 等价显式写法(容器内 dsh 只能源码方式运行):
# DSH_BIN='pnpm --dir /app dsh' ./scripts/smoke.sh
# 直接查运行中 host 的会话列表(含每会话投影与 fork 父):
curl -s -X POST http://127.0.0.1:3080/api/session.list -H 'Content-Type: application/json' \
  -d '{"type":"client-request","rpcId":"p","method":"session.list","payload":{}}'
# 统计缺 history 投影的会话数(host 缓存补齐的验收指标):
curl -s -X POST http://127.0.0.1:3080/api/session.list -H 'Content-Type: application/json' \
  -d '{"type":"client-request","rpcId":"p","method":"session.list","payload":{}}' > /tmp/sessions.json \
  && node -e 'const r=JSON.parse(require("fs").readFileSync("/tmp/sessions.json","utf8"));const it=r.result?.value?.items??[];console.log(`total=${it.length} withHistory=${it.filter(i=>i.projections?.values?.history).length} missing=${it.filter(i=>!(i.projections?.values?.history)).length}`)'
# 实测基准:host 补齐前 27/58 缺失 → 重启 GUI 后应归零(空 log 会话补齐 init 空态行也计入有值)
# 模拟浏览器执行 bundle(fake __ModuleLoader__ + fake React/useState/useSessions)
# 验证浏览器拿到新 bundle:curl / 看 __DSH_BOOT__ 里 rev == sha1(lib/client.js).slice(0,12)

# —— bundle 安装形态验证(2026-08-18 起)——
pnpm --dir /app dsh plugin --profile trail-test add /workspace/dsh-trail   # 安装 + 自动 reconcile 进 bundles
pnpm --dir /app dsh plugin --profile trail-test remove @deepseek-ai/dsh-trail-plugin 2>/dev/null || true  # 旧包名键清理
pnpm --dir /app dsh --profile trail-test --dump-config | grep -A4 "id: dsh-trail-plugin"   # 应见 "# == dsh-trail-plugin" 段、恰一行
# 浏览器侧:curl -s http://127.0.0.1:3080/ | grep -oE 'dsh-trail-plugin/client\.js\?rev=[0-9a-f]+'   # roster 在不在
# 验收口径注意:缺投影会话数按「有 log.jsonl 的会话」看(session.jsonl.zstd 压缩日志会话 backfill 不覆盖)

浏览器侧诊断(F12 Console)——左栏相关问题快速定位(面板在不在 DOM / 几何对不对):

(() => {
  const slot = document.querySelector('[data-slot="shell.overlay"]');
  const panel = slot?.firstElementChild;
  const conv = document.querySelector('[data-slot="conversation"] > div[data-phase]');
  return {
    overlaySlot: slot ? `存在,子元素 ${slot.childElementCount} 个` : '不存在',
    panelStyle: panel ? { left: panel.style.left, top: panel.style.top, width: panel.style.width, height: panel.style.height } : null,
    panelText: panel ? panel.textContent.replace(/\s+/g,' ').slice(0,60) : null,
    convPadding: conv?.style.paddingLeft,
    convRect: conv ? JSON.stringify(conv.getBoundingClientRect()) : null,
    slotErrors: [...document.querySelectorAll('[data-slot-error]')].map(e => e.getAttribute('data-slot-error')),
  };
})()

判读:overlaySlot 缺 → 条目未注册;折叠态时 panel 0 宽(panelStyle.width 为 "0px"),应看到 -> 右半圆展开按钮(Fragment 兄弟,直径边贴对话区左缘,expandButtonRef 位置由 applyLayout 折叠分支设置;展开态则在 panel header 右端见 <- 左半圆);panelStyle.left/height 异常(0/0)或 convPadding 空但面板在 → 几何未定位(渲染协调问题,见 §2)。

5. 踩坑记录

  • BRE grep\( 是分组符不是字面括号;固定串用 grep -F
  • tsc 产物保留 JSDoc:旧 build-client 靠 export default 标记切片(现已被 esbuild 取代)。
  • pnpm 11 首次装 esbuild 自动生成 pnpm-workspace.yamlallowBuilds 占位,需显式 esbuild: true
  • 测试不参与 typecheck(tsconfig 只 include src),vitest 只转译不查类型——pnpm verify 的 typecheck 步骤不可省。
  • 审批已禁用:动态插件(cordis_define/cordis_run)的 client 授权会被自动拒绝,验证别走动态插件路径。
  • 当前会话若无后代(例如是 fork 叶子),角标为 0 是正确行为;验证角标要切到有 fork 子会话的会话(可先用上面 curl 找 parentSessionId 反指某会话的行)。
  • "刷新后左栏消失"多半是折叠态误判:折叠态持久化在 localStorage dsh-trail.left-column{width, collapsed}),刷新后保持折叠。折叠态现在是面板 0 宽 + 悬浮 -> 右半圆展开按钮(直径边贴对话区内容左缘,恰落在官方 header 左 padding 区内;与展开态的 <- 左半圆折叠按钮同半径同 gap、拼合一个整圆);按钮不大且嵌在对话区 header 左上角,找不到会被当成"没了"——先查 [data-slot="shell.overlay"] 子元素与 -> 半圆按钮(title「展开历史索引」),不是 bug。
  • box-sizing 坑(header 高度偏高 13px):React inline style 默认 content-boxminHeight 作用于内容区(不含 padding)。左栏 header 若 padding-top 12 + minHeight 44 → 实际总高 12+44=56px,比官方 44px 高 12px。容器同时设 padding 与 minHeight 时必须 boxSizing: 'border-box'(minHeight 才含 padding)。
  • curl manifest 缓存时序pnpm build 后 curl 首页首次可能命中 index.html 缓存返回旧 rev,加 cache-buster(?cb=$(date +%s%N))再查即为新 rev——以 sha1(lib/client.js) 前 12 位为准。
  • 会话切换后左栏消失/竖条漂移 = 引用过期:conversation 槽位会话切换重挂载(DOM 节点替换),layout effect 闭包若缓存节点引用 → RO 观察 detached 节点永不触发、几何读取全 0。修复组合:effect deps 含 current + 每次实时查询节点 + 几何未就绪 rAF 重试(≤20 帧)+ 250ms 漂移轮询 + panelStyle 初始 height:100%。改几何逻辑时务必保持这套防御
  • RO 对 grid 列过渡(侧栏开合)时序不可靠:开侧栏触发、关侧栏可能漏触发 → 位置漂移。250ms 漂移轮询兜底(对比 panel.left 与会话列左缘,漂移>1px 重新定位;拖拽中跳过)。
  • 折叠态按钮锚点教训(半圆按钮重构):折叠态展开按钮若锚定"虚拟分割线 = 对话区左缘 + 记忆宽度"→ 按钮悬在对话区中间(远离左缘,易被误判为漂移 bug)。正确锚定 = 对话区内容左缘left = convRect.left − frameRect.left,不再加 widthRef),gap 0 恰好落在官方 header 左 padding 20px 区内、不压标题。
  • 拖拽手柄抢占紧贴右缘的按钮:拖宽手柄(right:0; width:8; zIndex:2)覆盖面板右缘 8px——折叠按钮若 gap 0 贴右缘(r=10 宽 20),右 8px(40%)点击会被手柄抢走(触发拖拽而非折叠)。**按钮必须 zIndex: 3(> 手柄 2)**或留出间距。
  • git merge 偶发 fatal: stash failed:曾出现一次(工作区干净、无自定义 hooks),重试即成功——环境偶发,遇此直接重试 merge。
  • fakeReact 的 useState setter 是空函数、渲染树是单次快照client.test.ts 的 fakeReact 一次 component(props) 调用产出一棵静态树,setState 不触发重渲染——hint 等 state 文案无法从渲染树断言。跳转流程测试改为断言副作用面vi.stubGlobal('document', {querySelector, querySelectorAll}) 桩 DOM、sessions.binding 返回 fake session(getSnapshot/loadOlder mock),点击行 onClick 后 await Promise.resolve() 数次驱动 async IIFE,断言 scrollIntoView/loadOlder mock 的调用。node 测试环境无 document,用到 DOM 的测试必须 stub(记得 vi.unstubAllGlobals() 清理)。
  • 跳转异步测试的时序:目标在窗口 → 0 次 loadOlder 直接滚(断言 scrollIntoView 被调、loadOlder 未被调);!hasMore 无目标 → 不空翻页(断言 loadOlder 未被调)。扩窗/翻页路径的完整用例受 fakeReact 快照限制未单测,靠 GUI 实测。
  • 视图激活判定用 [data-chat-flow] 而非 [data-conversation-scroll]:后者在 ConversationRoot 上无条件渲染(hero/无会话态也存在),用它判「聊天视图激活」会把 hero 态误判为活跃(跳转白翻页)。[data-chat-flow] 仅 ChatView 挂载时存在(见 §3)。
  • profile 的 cordis.patch.yml 删光条目后必须留顶层 []:patch 文件要求顶层数组;全注释文件 YAML 解析为 null → loadOverlayPatches 报「must be a top-level YAML array of loader patch entries」→ dump/boot 失败。web profile 迁移删掉唯一手工 insert 时踩到,补 [] 解决(profile 模板初始即 [])。
  • pnpm ERR_PNPM_IGNORED_BUILDSallowBuilds 值必须是布尔dsh plugin add 触发锁文件重新解析时,未放行的构建脚本(如 node-pty)整单失败;pnpm-workspace.yamlallowBuildsnode-pty: set this to true or false 这类字符串占位不算放行。web profile 的 node-pty(dsh-workbench-plugin 的依赖)占位已补 node-pty: true(任何触发重新解析的 dsh plugin 操作都会撞这个错)。
  • 容器内「重启 GUI」= 杀 dsh 进程 = 容器主进程退出:entrypoint.sh(PID 1)wait -n $DSH_PID $NGINX_PID,dsh 一死 entrypoint 即退出、容器停止;容器内无 docker socket / 无 sudo,无法给 entrypoint 加自愈循环——恢复完全取决于宿主侧 Docker 重启策略(未配 --restart 需宿主手动 docker restart)。bundle 层改动(dsh.profile.bundles必须整进程重启才生效(config HMR 只覆盖用户层 patch 文件,不重读 bundles 清单)。
  • session.jsonl.zstd(压缩日志)会话无 history 投影:实测 67 会话 5 个缺失均为只有 session.jsonl.zstd(无 log.jsonl)的旧会话,backfill/冷读不覆盖——既有数据条件,非插件回归;验收指标按「有 log.jsonl 的会话」口径看。

6. 代码结构约定

  • host:src/index.ts(注册投影单元,stateVersion=2;具名导出 inject = ['sessionProjections','sessionProjectionCache'] 声明必选服务,apply 内直读 ctx.sessionProjections / ctx.sessionProjectionCachesessionPersistencectx.get 可选)+ src/backfill.ts(启动后台补齐缺 history 缓存:backfillMissingHistory 纯编排,最小本地接口 SessionPersistenceLike/SessionProjectionCacheLike/SessionHeaderLike,不 import dsh 包);client:src/client.ts(default-export factory(require),返回对象带 inject = ['slots','sessions'],只注册 shell.overlay 左栏)。
  • 纯逻辑层 src/history/types.ts(节点类型)、text.ts(摘要/文本工具)、summarize.ts(整句规则摘要)、fold.ts(事件折叠 reducer)、schema.ts(zod,host-only)、lineage.ts(isDescendantOf / sharedPrefixLength)、index.ts(节点中心索引:rootOf / buildHistoryIndex / lineageForNode)。
  • 左栏模块src/client.tscreateLeftColumn(shell.overlay 面板:几何/让位/拖宽/折叠/行内跳转/瞬态提示/跳转缓冲指示/分叉展开/续写)+ src/left-column.ts(纯逻辑:clampColumnWidth 钳制 240–480/聊天保 480、readLeftColumnPrefs/writeLeftColumnPrefs localStorage 记忆)+ src/jump.ts(纯逻辑:matchTarget 精确匹配(同 turn 可见最小 anchorSeq,turn<0 跳过)、resolveFallback 邻近可见回退(同 turn 次小 → ≥startSeq 最近 → 全局最近,排除 excludeKey)、jumpFailureMessage 失败码文案(VIEW_INACTIVE/TARGET_HIDDEN±fallback/NOT_FOUND/TIMEOUT)、minAnchorSeq 翻页进度判断、JumpChatNodeLike/JumpChatNodeRawLike)。
  • 左栏组件返回 Fragment[panel div(折叠 0 宽), collapsed ? -> 右半圆展开按钮 : null]——展开按钮必须为 Fragment 兄弟(panel overflow:hidden);折叠态位置由 applyLayout 折叠分支计算(left = convRect.left − frameRect.left + EXPAND_BUTTON_DIVIDER_GAP(0)——贴对话区内容左缘,不再加记忆宽度;top = convTop + CENTER_Y + 垂直定心走 transform: translateY(-50%));展开态折叠按钮 <- 左半圆在 header titleRow 右端(header 右 padding 0 = gap 0 贴面板右缘,zIndex: 3 盖过拖拽手柄)。两按钮互斥、共用 railHovered无新增 state
  • 左栏 useState 调用序(fakeReact 注入序,勿打乱)prefs(0) → lineageOpen(1) → hoveredRow(2) → hoveredBranch(3) → jumpingNodeKey(4) → handleHovered(5) → railHovered(6) → dragging(7) → hint(8)。测试用 fakeReact([...states]) 按调用序注入初始值——新增 state 必须追加在末尾,否则现有测试注入错位。
  • 测试 tests/*.test.ts,import ../src/*.js;bundle 安全(client 不 import zod、不 import node 内置);client.ts 用 DOM API(document/window/ResizeObserver/requestAnimationFrame),tsconfig lib 已含 DOM。渲染树断言 helper:findByKey/findByText(props)、findElementByKey/findElementByTitle(完整元素含 children,结构断言用)。fake ctx.effect 桩必须立即执行 setup 并记录/返回 cleanup(cordis 语义,host-projection.test.ts 的接线用例依赖它触发异步补齐);断言服务方法带 signal 参数时用 expect.any(AbortSignal)(backfill 接线用例先例)。
  • 半圆按钮相关约定:几何常量 EXPAND_BUTTON_RADIUS=10 / CENTER_Y=28 / DIVIDER_GAP=0 定义在 createLeftColumnexpandButtonStyle 之前(样式定义区,勿移到组件外);测试断言:折叠态测试断言「展开历史索引」+ -> + Fragment 2 子元素(面板 0 宽/无 borderRight),展开态测试断言「折叠左栏」+ <- + 无 ->tests/client.test.ts)。

7. 下一步(按数据就绪度)

  1. host 侧补齐缺 history 的投影缓存 — ✅ 已实现并 GUI 实测通过(2026-08-18,feature/host-backfill,已 no-ff 合并回 main fdaae76):
    • 实现:src/backfill.ts backfillMissingHistorypersistence.list()cachedSnapshot(meta).values.history 缺失判定 → 逐个顺序 coldSnapshot(id, signal);错误隔离/abort 中断/幂等),src/index.ts 投影注册后 ctx.effect 接线(AbortController cleanup,服务缺席跳过);tests/backfill.test.ts(9 用例)+ tests/host-projection.test.ts 接线用例(2 个);102 测试全绿。
    • 验证结论(用户确认):重启 GUI 后 history backfill: 检查 N 个会话,补齐 M 个,跳过 K 个 日志出现,缺失数 27/58 → 0(§4 有统计脚本);打开旧对话稍等片刻即显示逻辑节点,功能无问题。
  2. 左栏交互补全(骨架/拖宽/跳转已完成;fork 续写 ✅、谱系角标/下拉 ✅): a. 点击节点行内跳转(完成:src/jump.ts 纯映射 + 左栏行 onClick;落点=轮首用户行;超出已加载窗口自动连点 session.loadOlder() 逐页翻页直到目标可见或 !hasMore(等价官方「加载更早」,feature/required-capabilities 5db5aa6 重写;100 页/15s 总超时保护,minAnchorSeq 无进展防空转);候选只取 visible 行(hidden 无 DOM 行);命中后轮询等行渲染进 DOM(8s 超时,视图消失即报「聊天视图未激活」);失败提示按码分类:VIEW_INACTIVE / TARGET_HIDDEN(±邻近回退「目标无独立气泡,已定位到邻近内容」)/ NOT_FOUND(「目标节点未加载或不存在(可能已压缩)」)/ TIMEOUT(「加载历史超时,可重试」))。 b. fork 续写入口迁移到左栏行(完成:行尾「续写」按钮 hover 显现(opacity/pointerEvents 随行 hover 态),点击 sessions.fork({sessionId: current, atSeq: boundarySeq, increaseTitle: true})open(childId),失败走 showHint 瞬态提示;进行中节点不渲染按钮)。 c. 谱系角标/下拉迁移(完成:左栏新增全量 useSessions selector → toLineageSessionsbuildHistoryIndex行首分叉数字(hover/展开变官方 chevron)点击展开共享会话下拉(叶子摘要 + 切换);展开体是行下方 column 兄弟(复刻官方 DisclosureRow 骨架,marginLeft 20 缩进,不再作为行内 flex item——修复撑高/挤占 bug);lineageForNode 复用,src/history/* 零改动)。 d. 窄屏自动折叠:convRoot 宽度低于阈值(约 MIN_CHAT + MIN 列宽)自动折叠(拖拽钳制已就位,仅差阈值触发)。半圆按钮已就位(折叠态 -> 右半圆贴对话区左缘、展开态 <- 左半圆贴面板右缘),阈值触发逻辑补上即可完整。 e. 已知边界:非聊天视图(trajectory)无法编程切换(chatStore 私有)→ 提示用户手动切回(VIEW_INACTIVE 文案);hidden-only turn 无 DOM 行 → 邻近可见回退;compacted turn 内容已被官方删除 → NOT_FOUND 文案带「可能已压缩」提示(真实出现再补 host fold 的 compaction 折叠:compaction/summaryshadowedRange/summary → compacted 节点,需 stateVersion 2→3);超深历史(>5000 条/100 页)→ TIMEOUT 提示可重试;跳转高亮留待 polish。
  3. 旧 tab 去留 — ✅ 已移除并 GUI 实测通过(2026-08-18,feature/remove-history-tab):删除 src/client.tsconversation.view 注册块 + createHistoryView 整块(含其局部样式、VIEW_ID/HistoryViewProps/KIND_ICONS);保留左栏共享的 toLineageSessionsSessionSummaryLike/SessionListStateLikeClientSessions/ClientSlotshistory/*/jump/left-column 纯逻辑与 shell.overlay 注册;tests/client.test.ts 删 4 个 tab 用例、改写 apply 注册断言(只剩 shell.overlay);98 测试全绿。GUI 实测通过(用户确认无问题):会话 header tab 环只剩「对话/Trajectory」两项,左栏全部功能(跳转/续写/角标/拖宽/折叠)正常。client 改动刷新即生效,无需重启 GUI(官方 view 环剩 chat/trajectory 两项,tabs 行照常,左栏 75px header 对齐不受影响)。
  4. M5 二级完整路径:数据已全在 client(每会话完整节点路径),基本是 UI。
  5. bundle 发布形态(2026-08-18 bundle 化已完成,npm 包名已定 dsh-trail-plugin 且 npm 上可用;2026-08-18 发布准备完成):npm 发布需 publish 前构建好 lib/files 已含 lib + cordis.patch.yml)——已加 prepublishOnly: pnpm verify(发布前自动 typecheck+测试+build)与 prepare: pnpm build(git 安装用);exports 移除了未随包发布的 ./src/*;补了 LICENSE(MIT);pnpm pack 产物 dsh-trail-plugin-0.1.0.tgz 已实测:装进独立 npmtest profile(dsh plugin add <tarball>)→ dump-config 恰一行 bundle 行 → boot 出 hello 日志,全绿。git 安装(dsh plugin --profile <name> add github:<owner>/<repo>)需包内 prepare: pnpm build + 用户侧在 profile 的 pnpm-workspace.yamlallowBuilds(pnpm ≥10 默认拦截 git 依赖构建脚本,报错会给出具体键);tarball(pnpm pack)路径无需任何放行。发布已执行dsh-trail-plugin@0.1.0 已上线 npm(2026-08-18,registry 读侧 CDN 有 ~3 分钟延迟属正常);完整发版流程见 §8.5。

8. 从 README 迁入的开发内容(2026-08-18:README 改为用户向上手指南)

README.md 自本轮起只面向用户(安装/使用/配置/FAQ),以下开发向内容全部迁至此。 原 README「数据链路(M1)」与「下一步」两节不重复迁入:前者架构细节在 DESIGN.md、摘要留在 README「工作原理」;后者已被本文件 §7 取代。

8.1 常用命令

pnpm install     # 安装依赖
pnpm typecheck   # 类型检查
pnpm test        # 跑单测
pnpm build       # tsc 构建 + esbuild 打 client bundle(lib/client.js)
pnpm verify      # 类型检查 + 测试 + 构建 一条龙
pnpm pack        # 打 npm tarball(含 prepare 自动 build)

8.2 完整目录结构

.
├── cordis.patch.yml      # bundle 层:dsh plugin add 安装后自动插入的组合行
├── package.json          # 包声明:name、exports["./client"]、dsh.bundle + dsh.client(platform: web)
├── tsconfig.json         # strict + NodeNext ESM
├── vitest.config.ts
├── scripts/
│   ├── build-client.mjs  # esbuild 打包 client → 浏览器模块加载器 handoff
│   └── smoke.sh          # 挂载验证(独立 trail-test profile 启动 DSH)
├── src/
│   ├── index.ts          # Host 插件入口:注册 history 投影单元
│   ├── client.ts         # Client 插件入口(./client 子路径,factory(require))
│   ├── history/
│   │   ├── types.ts      # 节点树共享类型(host 折叠 + client 渲染)
│   │   ├── text.ts       # 摘要/文本提取工具(纯函数)
│   │   ├── fold.ts       # 事件折叠:SessionEvent → 节点树(纯 reducer)
│   │   └── schema.ts     # zod schema(host 侧,校验 view 输出)
│   ├── options.ts        # 配置:类型 + schemastery Schema + normalizeOptions
│   └── lib.ts            # 纯业务逻辑占位
└── tests/
    ├── history-fold.test.ts    # 事件折叠单测(turn 分组/摘要/fork 边界)
    ├── host-projection.test.ts # host 投影单元注册与折叠
    ├── client.test.ts          # client bundle factory + 视图渲染
    ├── options.test.ts
    ├── lib.test.ts
    └── plugin-shape.test.ts    # 插件形状 + bundle 安装形态(patch/声明)校验

8.3 骨架遵循的 DSH 约定

约定依据
包形态ESM,main: lib/index.jsexports./client 子路径DSH 各包(如 @deepseek-ai/dsh-client-modules
插件形状具名导出 name / Config / apply(ctx, config)@deepseek-ai/dsh-hooks-claude-code
配置校验schemastery z.object({...})z<Options> 标注同上
Client 声明package.json dsh.client: { platform: "web", ... }packages/client/modules 扫描逻辑
日志ctx.logger('name'),核心服务无需 injectcordis 4 核心混入
副作用ctx.effect(() => () => {}) 保证停止/更新时清理cordis 4 Fiber

8.4 挂载验证(smoke test)

单测只能证明代码正确,**证明「DSH 启动时真的加载到了本插件」**要靠真实启动:

./scripts/smoke.sh

脚本做五件事(DSH_BIN / DSH_HOME / PROFILE 均可通过环境变量覆盖; 容器内 dsh 必须以源码方式运行,默认 pnpm --dir /app dsh):

  1. pnpm build 构建插件;
  2. dsh plugin --profile trail-test add .bundle 形态装进独立测试 profile(默认 trail-test,与正式 GUI 的 web profile 隔离)——包声明 dsh.bundle,安装后自动进 dsh.profile.bundles,组合行由 bundle 层插入, 无需手工写 profile 的 patch;脚本只补 bundle 层不提供的 storage 栈与 console logger(web profile 里这些行来自 web-app bundle);
  3. dsh --profile trail-test --dump-config 断言组合里恰好有一个 id: dsh-trail-plugin 行(来源为 # == dsh-trail-plugin bundle 层);
  4. 启动 DSH 抓启动日志,断言出现 hello world from dsh-trail-plugin (host)

启动日志形如:

[I] dsh-trail [dsh-trail] hello world from dsh-trail-plugin (host)

ctx.logger('dsh-trail') 的命名空间是日志第一段,[dsh-trail] 是配置里的 label 前缀——说明 configenabled: true, label: dsh-trail)被正确注入。

要把插件挂进正式 GUI(web profile,即本机 3080 端口那个): dsh plugin --profile web add <本仓库路径>(或 add link:/绝对路径), 确认 --dump-config 出现 bundle 层行,然后重启 GUI。 重启会中断当前会话,开发期建议先用 trail-test profile 验证。

8.5 发布到 npm

包形态:main/exports["./client"]/types 指向 lib/files 只含 lib + cordis.patch.yml + LICENSE,dsh.bundle 声明让 dsh plugin add 安装 后自动进 bundles 层。prepublishOnly 会在发布前自动跑 pnpm verify,保证 lib/(gitignored)始终是新的;preparepnpm build)供 git 安装场景使用。

# 一次性:登录 npm(需要有 npm 账号)
npm login            # 或 pnpm login

# 每次发版:
pnpm version patch   # 或 minor / major;也可手改 package.json
pnpm publish         # 先自动 pnpm verify(typecheck + 测试 + build),再打包上传
git push --tags

发布前可先用 pnpm pack 生成 dsh-trail-plugin-<version>.tgz 检查包内容 (npm pack --dry-run 只列出不生成)。包名 dsh-trail-plugin 已在 npm 确认可用。

已发布版本:0.1.0(2026-08-18,memoryit 账号)。registry 读侧 CDN 有 max-age=300 的缓存延迟(发布后 npm view 可能短暂 404,重试 publish 报 cannot publish over the previously published versions 即证明已落库)。

用户侧安装(与本地路径安装同一机制,只是从 registry 取预构建产物):

dsh plugin --profile <name> add dsh-trail-plugin        # 从 npm
dsh plugin --profile <name> add ./dsh-trail-plugin-0.1.0.tgz   # 从 tarball(无网络场景)

git 安装(add github:<owner>/<repo>)会拿到源码而不是构建产物,需要包内 prepare 脚本(已有:pnpm build)且用户侧在 profile 的 pnpm-workspace.yaml 放行 allowBuilds——优先走 npm/tarball 分发预构建产物。