--- id: 20260822-extension-slot-portal-three-column-grid title: 用生命周期插槽、稳定布局接缝和 CSS Grid 扩展宿主三列工作台 created: 2026-08-22 updated: 2026-08-24 status: candidate scope: global category: frontend-architecture confidence: medium last_verified: 2026-08-24 promotion_target: pattern projects: - oh-story-dsh tags: - host-extension - session-slot - react-portal - css-grid - container-responsive - native-chat --- # 用生命周期插槽、稳定布局接缝和 CSS Grid 扩展宿主三列工作台 ## Trigger 插件需要在不修改宿主源码、不复制宿主 Chat 的前提下,把文件树、编辑器等工作区与宿主原生会话界面组成三列布局;宿主提供的官方扩展点在视觉上是 overlay,但真正需要改造的是 Session 内已有内容区的排版。 ## Context `oh-story-dsh` 需要在 DeepSeek Harness(DSH)中提供“文件树 / 编辑器 / 官方 Chat”三列创作工作台,同时继续使用 DSH 原生 Chat、streaming、tools、Todo、approvals、history 和 Composer。 这个实现没有修改 DSH Host 源码,也没有把整个工作台直接画成覆盖宿主内容的浮层。它把两个职责不同的接缝组合起来: 1. 通过官方 `shell.overlay` 注册 Session 级子插槽 `oh-story.workspace`,取得 `SessionProvider`、`sessionId`、`useSession` 和 Session 生命周期内的 Store。 2. 子插槽组件定位 DSH 明确作为稳定地址接缝的 `conversation.session`,再用 React portal 把工作台挂进该会话容器。 3. CSS 只在工作台 portal 确实存在时,把 conversation scroller 切成三列 Grid;文件树占第 1 列,编辑器占第 2 列,仍然挂载着的官方 Chat 和 Composer 占第 3 列。 关键不是“用 Grid 画三列”,而是先把状态生命周期、DOM 布局落点和原生能力所有权分开,再用最小的布局改造把三者拼起来。 这也说明应先区分两类需求:`conversation.view` 适合增加一个与 Chat 并列、切换显示的整页 Tab;当文件树、编辑器和原生 Chat 必须同时可见时,单独注册 view 不够,需要让新增工作区进入 Chat 当前视图的共同布局上下文。 ## Evidence - 仓库:[worldwonderer/oh-story-dsh](https://github.com/worldwonderer/oh-story-dsh)。2026-08-22 初次核对提交 [`8fa6786`](https://github.com/worldwonderer/oh-story-dsh/commit/8fa6786aa9d107caf3072c66ab5df334f07b69c0);2026-08-24 再次浅克隆并核对 HEAD [`fce73ca`](https://github.com/worldwonderer/oh-story-dsh/commit/fce73cafff535ab80316b74e427c539351759fbc),关键架构仍一致。 - [`docs/ARCHITECTURE.md`](https://github.com/worldwonderer/oh-story-dsh/blob/8fa6786aa9d107caf3072c66ab5df334f07b69c0/docs/ARCHITECTURE.md):明确 Browser entry 使用 `shell.overlay`,把文件树和编辑器 portal 到稳定的 `conversation.session` 布局接缝,并保留官方 conversation view。 - [`packages/dsh-plugin/src/client/index.tsx#L796-L895`](https://github.com/worldwonderer/oh-story-dsh/blob/fce73cafff535ab80316b74e427c539351759fbc/packages/dsh-plugin/src/client/index.tsx#L796-L895):`apply()` 在 `shell.overlay` 下声明 `{ "oh-story.workspace": { kind: "single", scope: "session" } }`;`WorkbenchSeat` 通过 `SessionProvider` 渲染子插槽;`CreativeSplitBridge` 查找 `[data-conversation-scroll] > [data-slot='conversation.session']` 并调用 `createPortal()`。该入口没有注册或替换 `conversation.view`。 - 同一文件中的 `CreativeSplitBridge` 使用 `ResizeObserver` 读取 conversation scroller 的 `clientWidth`,以 `<620`、`<900` 和其余宽度写入 `compact`、`medium`、`wide` 容器布局状态;卸载时移除 CSS 变量和 `data-oh-story-layout`。 - [`packages/dsh-plugin/src/client/plugin.css#L1-L75`](https://github.com/worldwonderer/oh-story-dsh/blob/fce73cafff535ab80316b74e427c539351759fbc/packages/dsh-plugin/src/client/plugin.css#L1-L75):只有当 `conversation.session` 中存在 `.oh-story-split-surface` 时,`:has()` 选择器才把 scroller 设为 Grid。wide 列轨为 `clamp(184px, 16%, 200px) minmax(240px, 1fr) clamp(408px, 40%, 520px)`;tree、editor、官方 Chat 分别进入第 1、2、3 列。 - 同一 CSS 把 `[data-composer-seat]` 放到第 3 列并设为 sticky,同时给 `[data-chat-flow]` 留出 Composer 高度,避免原生 Composer 覆盖最终消息;`min-width: 0`、`min-height: 0` 和各列 overflow 规则负责允许 Grid 子项正确收缩和滚动。 - [`plugin.css#L352-L358`](https://github.com/worldwonderer/oh-story-dsh/blob/fce73cafff535ab80316b74e427c539351759fbc/packages/dsh-plugin/src/client/plugin.css#L352-L358) 定义 medium/compact 列轨;源码未发现拖拽分隔条或 pointer/mouse move 调宽逻辑,因此当前列宽由 `clamp()` 和容器断点决定。 - [`scripts/native-dsh-smoke.ts`](https://github.com/worldwonderer/oh-story-dsh/blob/8fa6786aa9d107caf3072c66ab5df334f07b69c0/scripts/native-dsh-smoke.ts):原生 DSH smoke test 读取 tree、editor、Chat 和 Composer 的 bounding box,验证三列顺序、列宽至少 120px、Composer 位于 Chat 列内,并在滚动回归中检查 Composer 不消失。 - 2026-08-22 用户明确纠正:该项目“没有修改 DSH Host,也不是单纯浮层”;这是本条要保留的关键架构辨析。 - 2026-08-22 本次 capture 通过浅克隆核对当前提交、上述源码和测试代码;没有实际运行仓库的原生 DSH smoke test。 - 2026-08-24 用户提供该项目实际 UI 截图,视觉上与源码一致:左侧项目/文件树、中间 Markdown 预览、右侧原生消息流与 Composer 同时出现。截图证明最终呈现,不单独证明交互和卸载行为。 - 2026-08-24 Context7 将官方 DSH 解析为 `/deepseek-ai/deepseek-harness`;官方 [`ui-layout`](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/ui-layout/src/client/index.ts) 将 `shell.overlay` 声明为 root-scoped list slot,官方 [`ui-conversation` SlotMap](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/ui-conversation/src/client/contract/slots.ts) 将 `conversation.view` 声明为 session-scoped list slot,验证两者职责边界。 - 项目侧完整复核记录:`dsh-lark-cli/.scratch/research/oh-story-dsh-three-column-layout.md`。 ## Root cause 已验证:`shell.overlay` 在此实现中承担的是官方扩展注册和 Session 生命周期桥接,不是最终三列布局的空间父节点。真正的布局落点是 `conversation.session` 所在的稳定 DOM 接缝。 已验证:官方 Chat 和 Composer 没有被插件重写。插件保留它们现有的挂载和状态所有权,只通过 Grid placement 把它们排到第 3 列;因此 Chat 内部能力仍由 DSH 实现。 已验证:三列布局的启用条件与 portal 内容共存。`.oh-story-split-surface` 消失后,`:has()` 不再命中,scroller 会自然退出插件 Grid 规则;组件清理逻辑同时移除容器状态。 已验证:该方案不是“利用 `shell.overlay` 直接画出三栏”。`shell.overlay` 只提供 root registration、SessionProvider 和自定义 Session slot;三栏空间关系发生在 portal target 所在的 conversation scroller。 已验证:注册新的 `conversation.view` 会得到一个与 Chat 并列、按 active id 单独渲染的视图,不能直接实现“工作区与原生 Chat 同时可见”。并存需求与切换需求必须使用不同扩展策略。 推断:这套设计稳定的原因是把“在哪里取得上下文”和“在哪里参与布局”拆成两层。扩展 API 的名字或默认视觉形式不必成为最终布局结构,只要宿主另有明确、稳定、可寻址的布局接缝。 推断:保留官方 Chat 的 DOM 和状态,比复制一套 Chat UI 或代理内部状态更能降低宿主升级时的兼容成本;但 portal 目标和 CSS 选择器仍属于宿主契约,必须由文档和回归测试保护。 ## Preferred action 1. 先判断产品需要的是“切换到插件整页”还是“插件 UI 与宿主原生视图同时可见”。前者优先使用 view/tab slot;只有后者才需要组合生命周期 slot 与布局 seam。 2. 画清所有权边界:插件只拥有新增工作区,宿主继续拥有 Chat、Composer、审批、工具调用和历史等原生能力。 3. 把接入拆成两种接缝: - 生命周期接缝:只负责获得 Session 上下文、Store 和卸载边界; - 布局接缝:只负责让新增 UI 与宿主现有 UI 参与同一个排版上下文。 4. 用官方扩展槽声明 Session scoped child slot,不自行订阅全局 Session,也不把跨 Session 的编辑状态放进单例 Store。 5. 只把 portal 挂到宿主明确承诺稳定的地址节点。若只能找到内部类名或偶然 DOM 层级,先补宿主契约或适配层,不要把猜测当扩展 API。 6. 在稳定内容容器上建立 Grid,显式给新增区域和原生区域分配列;不要复制或重新挂载宿主 Chat。 7. 用 portal 内容自身作为布局启用标记,例如父容器 `:has(.plugin-surface)`。这样插件未加载、切换 Session 或卸载时,不需要额外 JavaScript class toggle 就能恢复原布局。 8. Grid 轨道同时表达最小可用宽度、弹性和上限:窄导航列适合 `clamp()`,主编辑区适合 `minmax(0, 1fr)`,需要保障可用性的原生 Chat 适合带下限和上限的 `clamp()`。 9. 给可收缩 Grid 子项设置 `min-width: 0`,给内部滚动区域设置 `min-height: 0` 和明确 overflow;否则内容的固有尺寸可能撑破轨道。 10. 宿主 Composer 若与 Chat 流不是同一个 Grid item,应单独放入 Chat 列,并为消息流预留 Composer 高度;滚动时验证其可见性,而不只验证静态截图。 11. 响应式判断优先观察实际布局容器宽度,而不是浏览器 viewport。插件可能运行在侧栏、分屏或可变宽宿主中,`ResizeObserver + data-layout` 比 viewport media query 更贴近真实约束。 12. 几何回归至少检查:tree/editor/Chat 的左右顺序、每列最小宽度、Composer 是否完全位于 Chat 列、长 Chat 滚动时 Composer 是否保持可见,以及插件卸载后宿主是否恢复原布局。 ## Boundaries - 该模式只适用于宿主提供官方生命周期扩展点,并明确承诺某个 DOM 节点或 `data-slot` 是稳定布局接缝的情况;不能把任意 DOM 查询合理化为公共 API。 - `shell.overlay`、`conversation.session`、`SessionProvider` 和具体选择器是 DSH 当前扩展契约,不是 React 或插件系统的通用命名。 - `:has()` 需要目标浏览器版本支持;若宿主兼容范围包含旧浏览器,需要等价的受控 class/data attribute 退出机制。 - `display: contents` 会改变元素生成布局盒的方式,并可能影响可访问性、定位和浏览器兼容;应用前必须在宿主实际浏览器上验证。 - 三列最小宽度和 620/900 断点来自当前创作工作台,不应原样复制到内容密度、字体和宿主宽度不同的产品。 - portal 保留 React 上下文,但不会自动保证宿主 CSS、焦点层级、z-index、滚动和可访问性正确;这些仍需在真实宿主中做集成测试。 - 当前证据验证了源码设计和已有 smoke test 的检查项,但本次没有运行需要真实 DSH 环境的 smoke test,因此保留 `candidate` 和 `medium` confidence。 ## Failed approaches - 把 `shell.overlay` 的名字直接理解成最终视觉实现:会漏掉它在本项目中实际承担的 Session 生命周期和子插槽声明职责。 - 为了获得三列而复制或替换官方 Chat:会接管 streaming、工具、审批、历史和 Composer 等宿主内部状态,扩大维护边界。 - 只把工作台 portal 进会话但不改变共同父容器的布局:它仍只是会话中的普通内容或覆盖层,不能与原生 Chat 形成真正并列的三列。 - 用 viewport media query 推导列宽:宿主内部 conversation 容器可能与窗口宽度不同,分屏或侧栏变化时会得到错误布局。 - 只做视觉截图、不测 DOM 几何和滚动:容易遗漏 Composer 跨列、末尾消息被遮挡、窄列低于可用宽度等回归。 ## Examples 下面是该模式的结构化伪代码,不是可直接复制的 DSH API: ```tsx registerLifecycleSlot({ children: { workspace: { scope: "session" } }, }, ({ SessionProvider }) => ( {() => renderSlot("workspace")} )); function WorkspaceBridge() { const target = findDocumentedConversationSeam(); return target ? createPortal(, target) : null; } ``` ```css .conversation-scroller:has(.workspace-surface) { display: grid; grid-template-columns: clamp(var(--tree-min), 16%, var(--tree-max)) minmax(0, 1fr) clamp(var(--chat-min), 40%, var(--chat-max)); } .workspace-tree { grid-column: 1; min-width: 0; } .workspace-editor { grid-column: 2; min-width: 0; } .native-chat, .native-composer { grid-column: 3; min-width: 0; } ``` ## Promotion record - Not promoted. 当前只有 `oh-story-dsh` 一个项目的源码和测试证据;待在第二个宿主扩展中独立复用并通过真实集成测试后,再考虑提升为通用 pattern 或宿主扩展布局检查清单。