diff --git a/01_content/src/client/split.tsx b/01_content/src/client/split.tsx index 4f8d386..63c45a1 100644 --- a/01_content/src/client/split.tsx +++ b/01_content/src/client/split.tsx @@ -40,6 +40,9 @@ type SplitState = { savedMarginTop: string observer: ResizeObserver | null fallback: MutationObserver | null + yieldObserver: MutationObserver | null + lastMarginLeft: string + lastMarginTop: string listeners: Set<() => void> open(spec: LayoutSpec): boolean close(): void @@ -95,6 +98,12 @@ function persistSaved(layoutId: string, s: { chatW: number; topH: number; paneWs } catch {} } +/** 共享互斥协议:其他接入本协议的分栏引擎声明占用时,本引擎让位(同一时刻仅一个分栏工作区) */ +window.addEventListener('dsh:split-claim', ((e: any) => { + const id = e?.detail?.id + if (splitStore.active && id && id !== splitStore.spec?.id) splitStore.close() +}) as EventListener) + export const splitStore: SplitState = { active: false, spec: null, @@ -110,10 +119,29 @@ export const splitStore: SplitState = { savedMarginTop: '', observer: null, fallback: null, + yieldObserver: null, + lastMarginLeft: '', + lastMarginTop: '', listeners: new Set(), open(spec) { - if (this.active) this.close() + if (this.active) { + // 反选:同一布局再点 = 关闭;不同布局 = 替换(先关旧的) + if (this.spec?.id === spec.id) { + this.close() + return true + } + this.close() + } + // 兼容桥:travelatlas 尚未接入共享引擎(不改动其代码),打开本引擎布局前运行时点击其关闭按钮 + try { + const taClose = document.querySelector('.ta_splitClose') + taClose?.click() + } catch {} + // 声明占用:接入共享协议的其他引擎收到后让位 + try { + window.dispatchEvent(new CustomEvent('dsh:split-claim', { detail: { id: spec.id } })) + } catch {} const root = findConversationRoot() if (!root || root.dataset.phase !== 'active') return false const header = root.children[0] as HTMLElement | undefined @@ -152,6 +180,14 @@ export const splitStore: SplitState = { this.syncAnchor() }) this.fallback.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-phase'] }) + // 让位观察器:会话视图区 margin 被外部改写(其他未接入协议的分栏引擎接管)时关闭自身 + this.yieldObserver = new MutationObserver(() => { + if (!this.active || !this.viewArea) return + if (this.viewArea.style.marginLeft !== this.lastMarginLeft || this.viewArea.style.marginTop !== this.lastMarginTop) { + this.close() + } + }) + this.yieldObserver.observe(viewArea, { attributes: true, attributeFilter: ['style'] }) this.active = true this.notify() return true @@ -216,8 +252,12 @@ export const splitStore: SplitState = { const topH = hasTop ? clamp(this.topH, spec.topHeight?.min ?? 80, Math.max(spec.topHeight?.min ?? 80, rowH - BAR_H - 80)) : 0 - viewArea.style.marginLeft = Math.max(0, colW - chatW) + 'px' - viewArea.style.marginTop = (BAR_H + topH) + 'px' + const ml = Math.max(0, colW - chatW) + 'px' + const mt = (BAR_H + topH) + 'px' + this.lastMarginLeft = ml + this.lastMarginTop = mt + viewArea.style.marginLeft = ml + viewArea.style.marginTop = mt }, setChatW(w) { @@ -297,6 +337,8 @@ export const splitStore: SplitState = { this.observer = null this.fallback?.disconnect() this.fallback = null + this.yieldObserver?.disconnect() + this.yieldObserver = null this.root = null this.header = null this.viewArea = null diff --git a/02_process/PRD.md b/02_process/PRD.md index 2458a03..e0e5f94 100644 --- a/02_process/PRD.md +++ b/02_process/PRD.md @@ -359,6 +359,12 @@ type SplitPane = { (本版布局模型 = 标题栏 + 顶部通栏行(可选) + 主行内容窗 + 右下聊天窗;聊天窗 marginLeft+marginTop 组合挤法;会话切换重锚定不关闭;chat/top/pane 三级分隔线拖拽;`dsh.worktable.split.v1` 持久化), owner props 新增 `openSplit(spec)`;⬜ 待做:拓扑预设选择器与「+」面板「新建工作区」UI; +- **互斥规则(2026-08-16 用户反馈定案)**:同一时刻仅一个分栏工作区—— + ① 反选:同一项目卡片再点 = 关闭;② 替换:不同项目互斥(选 B 关 A); + ③ 实现:引擎内开前先关旧;对外广播 `dsh:split-claim` 共享协议并监听让位; + 对未接入协议的引擎(travelatlas 现行实现)用运行时兼容桥(点击其关闭按钮)+ 让位观察器 + (视图区 margin 被外部改写即让位),不改动其代码,待其迁入引擎后移除; + ④ 多项目并行 = 用户开多个浏览器窗口(网页窗口只容纳一个项目); - **M2 内容插件协议**:PaneProvider 三态接口;travelatlas 图鉴作为第一个内容窗迁入验证; - **M3 内容插件库**:资源管理器 / 终端 / 任务管理逐个接入;SCM 视 API 情况;自定义 vibe 闭环。 diff --git a/02_process/worklogs/2026-08-16_worktable-v2.md b/02_process/worklogs/2026-08-16_worktable-v2.md index 7a30a53..b53a44c 100644 --- a/02_process/worklogs/2026-08-16_worktable-v2.md +++ b/02_process/worklogs/2026-08-16_worktable-v2.md @@ -231,3 +231,18 @@ node_modules/dsh-planreview junction 已建;服务端已实时下发新 worktable bundle (served len=59982,含 SplitWorkspace/splitStore/openSplit)。 - 待验证:重启 dsh web 后 GUI 验收(见 dsh-planreview PRD §4 清单)。 + +## 补记(反选 + 项目互斥规则) + +- 用户反馈:① 建筑审图卡片没有反选(再点应关闭,travelatlas 有);② 项目间应互斥—— + 选 B 关 A,网页窗口同一时刻只容纳一个项目;多项目并行应靠多开浏览器窗口。 +- 实现(工作台 split.tsx,不动 travelatlas): + ① 反选:open(spec) 若同 id 已激活 → close 并返回; + ② 替换:开前先关旧(引擎内天然互斥); + ③ 共享协议:打开时广播 window CustomEvent 'dsh:split-claim',并监听让位(其他接入 + 协议的分栏引擎据此互斥——未来项目默认继承); + ④ 未接入协议的引擎兼容:打开前运行时点击 .ta_splitClose 关闭 travelatlas 分栏 + (不改其代码,迁入引擎后移除);另设让位观察器——视图区 margin 被外部改写即关闭自身 + (覆盖「先开审图再点旅行」的反向场景)。 +- 验证:构建 + node --check 通过;服务端实时下发(含 split-claim/yieldObserver/ta_splitClose + 标记);待用户刷新后实测两个方向与反选。