feat(injection): 统一面板宿主注入重构——含块层+几何写路径+chunk缓存+锚点弱化+桌面/移动适配 (#232)

* feat(injection): 统一面板宿主——固定含块层 + 挂载自检降级 + body 锚点守卫

- Sidebar 渲染根包 [data-dsh-panel-host](fixed inset-0 pointer-events:none)
- 面板/开关簇由 fixed 改 absolute(层内视口坐标等价),免疫套壳中间层 transform 劫持
- 挂载后一次性几何自检:页面级 transform 时降级 absolute + rAF 视口同步
- MutationObserver(body childList)守卫锚点被宿主清空时重挂

* perf(injection): 几何写路径统一为 writeGeometry + 面板 layout 隔离

- 拖拽帧与打开态共用单一推挤写入器(--dsh-sidebar-width/height)
- .panel/.bottomPanel contain: layout style——内部重排不外泄
- 拖拽期(body[data-dsh-sidebar-dragging])才挂 will-change: transform

* perf(chunks): HMR 激活保留未变 chunk 的已解析 exports + ETag HEAD 重验证

- 新增 revalidateChunksOnReactivate():激活时 HEAD 每个已加载 chunk 对比 ETag,
  未变保留内存缓存(跳过重注入/重执行),变更或不可达则 fail-open 重取
- 测试夹具与孤儿缓存条目不跨激活存活
- index.tsx 激活路径由 resetChunks() 改为 revalidateChunksOnReactivate()

* fix(layout): 推挤锚点改 data 属性 + #root calc 宽度防桌面壳溢出

- nth-child(2) → #root [data-dsh-frame] > [data-pane="conversation"](实证同元素)
- #root 宽度 calc(100% - var) 与 margin 同步过渡:web 视觉不变(实测 1140+300=1440 无溢出),
  Desktop 壳(#208)width:100% 的加性溢出根因消除

* feat(desktop): 桌面信号自动探测 + win32 顶栏 overlay 自动避让 + 键盘/safe-area 适配

- 新增 desktop-env.ts:解析 dsh-desktop-mode/platform URL 信号与 preload 标记
- win32 advanced 壳自动启用 titleBarCompat(32px overlay),手动 pref 仍可覆盖
- visualViewport 键盘 inset:底部面板/窄屏抽屉键盘弹起时上移
- 面板与开关簇 safe-area-inset 适配(env() 桌面端为 0)
- 单测 7 例(含 URLSearchParams 前导 ? 剥除修复)

* docs+e2e: 注入方案实施记录(路线B关闭、偏差记录、基线数据)+ 契约同步 + e2e 新断言

- 设计文档:路线 B 标记关闭、§8.1 改单 PR 步骤(含 commit 对照)、拖拽基线数据、实施偏差记录
- AGENTS.md §8:data-dsh-panel-host 含块层、推挤锚点契约(禁 nth-child)、桌面信号
- mount.e2e:data-dsh-panel-host 断言 + win32 advanced 桌面信号场景

* fix(cr): 响应 #232 CR 三条 findings

- P1 降级同步:按未修正几何判定(跟踪自身补偿量),祖先 transform 消失才退出循环——
  修正后矩形不再被误判为无需修正(自清除缺陷)
- P1 chunk 重验证成为 loadChunk 屏障:重验证挂起期间不提供缓存,杜绝 HMR 竞态陈旧 exports;
  测试夹具加载不再被误记为生产加载
- P2 键盘 inset:innerHeight - (vv.height + vv.offsetTop),并监听 scroll(offsetTop 变化)
- 新增屏障单测 1 例;设计文档偏差记录更新

* fix(chunks): 屏障 HEAD 加超时兜底 + resetChunks 清 revalidation

- revalidateChunksOnReactivate 的 HEAD 与 recordEtag 均带
  AbortSignal.timeout(5s):路由挂起时 fail-open(drop + 重取),
  屏障不再可能无限期阻塞所有懒加载 chunk(CR #232 P1 修复的健壮性缺口)
- resetChunks 同步清 revalidation:重验证挂起期间重置后,
  loadChunk 不再等待已丢弃状态的孤儿任务(finally 身份判断 no-op)
- 单测 +2:HEAD 挂起超时 fail-open(fake AbortSignal.timeout 触发中止)、
  resetChunks 清屏障后 loadChunk 立即放行

* fix(deps): align @deepseek-ai/* peers with DSH 0.1.0-rc.8

- peer/devDependencies: 13 个 @deepseek-ai/* 从 ^0.1.0-rc.7 升至
  ^0.1.0-rc.8;删除随 rc.8 消失的 dsh-client-web-react / dsh-client-schema-form
  (无 rc.8 发布、rc.8 依赖树与壳 bundle 零引用、插件 src 零引用);
  cordis peer/devDep 同步升 ^4.0.0-rc.8
- 版本 0.13.1 -> 0.14.0(package.json / dsh.plugin.json /
  SIDEBAR_SERVICE_VERSION 三处同步)
- CHUNK_EXTERNALS / CLIENT_EXTERNALS / CLIENT_REQUIRE_ALLOWED 三处镜像
  同步删除两个死包(primitives/slots 仍是 rc.8 壳静态模块表种子,保留)
- CI 挂载冒烟钉版 @deepseek-ai/dsh@0.1.0-rc.7 -> rc.8
- pnpm clean --lockfile 全量重解析:lockfile 内 @deepseek-ai/* rc.7 残留
  清零(传递链同升 rc.8,杜绝 #206 式混用树);pnpm-workspace.yaml
  minimumReleaseAgeExclude 按 #207 模式整体换为 rc.8 列表
- 文档同步:README 安装说明、AGENTS.md CI 钉版、外部插件指南与设计文档
  cordis 示例、issue 模板版本示例

* feat(prefs): editorExplorer 默认独立(关)——文件各自新开 tab

- SIDEBAR_PREFS_DEFAULTS 与 host PrefsSchema 的 editorExplorer 默认
  true -> false:新会话树点击/打开文件按 path 新开 tab,无路径窗口即
  纯资源管理器;合并模式保留为可选手动开启(持久化过的用户设置不受影响)
- 测试同步:prefs.spec 默认断言翻转、plugin-shape.spec 与 smoke.spec
  默认对象翻转;editor-host 合并模式用例显式开启 editorExplorer(split
  用例本已显式);side-card-section-rows 的 select 行用例固定选中 Merged
- e2e 重写:mount.e2e 文件打开流程从 in-place 断言改为独立模式
  (新开 hello.txt tab、Files home tab 保留),文件头注释同步
- 契约文档同步:AGENTS.md §3.4 editor 行默认描述翻转 + CI 冒烟描述、
  README/README_EN v0.13.0 特性描述 + v0.14.0 changelog

* fix(client): rc.8 模块系统经 ctx.modules 注入 + chunk 副本全局共享

rc.8 不再暴露 window.__DSH_MODULES__ 页面全局(改由 ctx.modules 服务
提供),chunk 外部依赖解析全面失效(terminal/editor/mermaid 全部
"client module system unavailable")。修复:

- src/client/index.tsx: inject 增加 'modules'(Cordis 服务访问守卫,
  此前直接读 ctx.modules 抛 "cannot get property modules without
  inject");apply() 激活时 setChunkModuleSystem(ctx.modules)
- package.json dsh.client.inject 增加 @deepseek-ai/dsh-client-modules
- chunk-loader: 注入写插件自有页面全局 __dshSidebarModuleSystem__——
  懒加载 chunk bundle 各自内联一份 loader(rc.7 靠官方全局回退工作,
  rc.8 全局消失后副本注入变量恒空);moduleSystem() 按
  注入变量 → 插件全局 → rc.7 全局回退解析
- context-types: Context 镜像补 modules 服务声明(client half)
- e2e: assertNoCrash 失败时输出错误条文本;独立模式断言修正
  (Files 数量按存在性、mermaid 流程先切回 Files、md 预览 locator 去掉
  重复的 [data-dsh-better-sidebar] 嵌套)

验证:typecheck / 639 测试全绿;本地 rc.8 CLI + Chromium 挂载冒烟 3/3
通过(drag-layout、mount 深扫含 editor/mermaid chunk、桌面信号)

* feat(settings): 终端 shell/shellArgs 可在设置页配置(bash 管理)

- SidebarPrefs 新增 terminalShell / terminalShellArgs(默认空 = 保持
  yaml config.shell → $SHELL / 登录 shell / powershell.exe 的既有解析)
- 终端卡齿轮二级页面新增两行 text 配置(与字体行同列)
- host 侧生效链路:UI 终端 WS 打开与 agent terminal_create 均读取
  设置文档覆盖(ptyManager.open / AgentPtyRegistry.create 增加可选
  shell 覆盖参数;shellOverridesOf 解析设置文档);设置页写入即时
  影响之后打开的终端(既有 pty 保持原 shell)
- locales zh/en 文案;parsePrefs 校验新字段;测试同步

* feat(settings): 侧边卡片设置页加 DSH-better-sidebar 版本徽标

- 设置页 intro 下新增身份徽标:名称 + v{service.version}(服务实例
  版本,与 package.json 由 tests/service.spec.ts 保持同步)
- token 驱动样式(--dsw-alias-*),名称/版本 pill 布局

* fix(drag): 快速拖动提交最后位置 + HMR 后中心列重定位兜底

快速释放(浏览器合并/丢失 pointermove 突发,超快速 flick 甚至可能
完全无 move)时的回退问题:
- pointerup 优先采用最后一次已知拖动值(rAF pending),而非 up 位置
  (事件合并时可能等于按下位置)
- pointercancel / lostpointercapture 中断时同样提交最后已知位置;
  无 move 时用中断事件自身的坐标计算(clamp 后提交)——只有真正
  未产生位移的拖动才回滚
- commitDrag 统一 flush + 同值提交 + 立即重测中心列(消除提交后
  React 用旧 centerRect 覆盖底栏 right 的中间帧)

HMR 重激活后底栏空白(输入框被推挤上移、强制刷新恢复):
- 根因:新组件实例重跑 locate 时壳的 centerCol 尚未就绪,而壳重建
  复用 #root 子节点(childList 观察器不触发)→ centerRect 卡 {0,0}
  → 底栏 visibility:hidden 永久空白
- 修复:额外观察 <html> 的 style 属性(layout push 写回 --dsh-sidebar-*
  时重定位重测);底栏打开时 effect 重跑 locate/measure 兜底
- 底栏未测量时由「不渲染」改为 visibility:hidden(渲染链不被几何
  门控,jsdom 测试不回归)

e2e 新增:极快拖动(单 move 立即 up)提交拖动位置、稳定后不回退

* feat(plugins): 添加插件目录支持搜索/分组/独立滚动(多插件场景)

- PluginListBody 顶部加实时搜索框(按名称/id/描述过滤,空结果提示)
- PluginEntry 增加可选 category 分组字段(i18n 友好),目录条目按组
  渲染(未分类条目保持平铺);数量徽标跟随过滤结果
- 条目列表 max-height + overflow-y 独立滚动,插件多时弹窗不再无限
  增长;渲染数量由搜索天然收敛
- locales zh/en(搜索占位、无匹配);测试文件固定英文环境
  (vitest 4.1.11+ jsdom 跟随系统 locale,本机 zh-CN 导致英文文案
  断言漂移)

* chore(deps): 适配 pnpm 11.8 supply-chain 校验(exclude 通配)

pnpm 11.8.0 起对 lockfile 既有条目强制 minimumReleaseAge 校验,
且精确 exclude(@scope/pkg@version)对带 peer 依赖后缀的解析条目
匹配失效(44 条误报违规,CI 全红)。@deepseek-ai/* scope 为官方
可信 scope,minimumReleaseAgeExclude 改为 '@deepseek-ai/*' 通配
(保留 rc.1 历史与 stable 条目);通配经实测通过全部 504 条校验。

* docs: v0.14.0 changelog 补全(自 v0.13.1 全部更改 + 特别声明 rc.8 适配)

新增条目:终端 shell/shellArgs 设置页配置、设置页版本徽标、
添加插件目录搜索/分组/滚动、rc.8 模块系统迁移、拖拽健壮性修复;
版本说明块醒目声明本版适配 DSH 0.1.0-rc.8(rc.7 及更早无法解析依赖)
This commit is contained in:
Atlas Zeng
2026-08-20 02:09:36 +08:00
committed by GitHub
parent 14ef8e4df8
commit 6c891514b5
48 changed files with 2610 additions and 1240 deletions
+231 -59
View File
@@ -1,16 +1,19 @@
/**
* The sidebar shell: fixed-position panels portalled onto document.body
* (the core AppFrame owns the left sidebar / center / details columns and
* has no right-side hole for plugins). The right panel hosts the original
* workbench; the bottom panel hosts a second, independent workbench. The
* bottom panel squeezes ONLY the center column (the agent output area): it
* spans from the app shell's own left sidebar to the right panel's left
* edge, so neither sidebar gives up any position (the right panel keeps its
* full height). A persistent two-button cluster at the top-right corner
* toggles each panel; the right panel's width drags from its left edge, the
* bottom panel's height from its top edge, and the shared corner drags both
* at once. The whole layout lives in the per-session store, so switching
* conversations swaps the sidebar.
* The sidebar shell: panels mounted inside the unified panel host — a
* fixed, viewport-sized containing block ([data-dsh-panel-host]) appended
* to document.body — instead of individual fixed-position elements, so a
* desktop shell's intermediate wrapper transforms can never hijack the
* panels' fixed containing block (the core AppFrame owns the left sidebar /
* center / details columns and has no right-side hole for plugins). The
* right panel hosts the original workbench; the bottom panel hosts a
* second, independent workbench. The bottom panel squeezes ONLY the center
* column (the agent output area): it spans from the app shell's own left
* sidebar to the right panel's left edge, so neither sidebar gives up any
* position (the right panel keeps its full height). A persistent two-button
* cluster at the top-right corner toggles each panel; the right panel's
* width drags from its left edge, the bottom panel's height from its top
* edge, and the shared corner drags both at once. The whole layout lives in
* the per-session store, so switching conversations swaps the sidebar.
*
* The shell binds the workbench actions to the store and dispatches tab
* content to the views. New tabs come from the + menu (explorer / git /
@@ -40,6 +43,7 @@ import {
import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
import { useNarrowViewport } from './breakpoints.ts'
import { parseDesktopEnv } from './desktop-env.ts'
import type { NewTabOption } from './TabBar.tsx'
import type { TabDragPayload } from './TabBar.tsx'
import { relativeTo } from './paths.ts'
@@ -137,6 +141,37 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// keep living in the right tree.
const narrow = useNarrowViewport()
// On-screen keyboard / visual-viewport inset (mobile, split-screen, …):
// when the visual viewport shrinks below the layout viewport, bottom-
// anchored panels would hide under the keyboard. Track the inset and
// offset the bottom-anchored surfaces by it. The obscured bottom strip is
// innerHeight − (vv.height + vv.offsetTop): offsetTop is nonzero while
// the visual viewport is scrolled/zoomed under browser chrome, so
// omitting it would over-lift the panels (CR #232 P2). offsetTop changes
// through the viewport's scroll event too, so both events are listened.
// Guarded: browsers without visualViewport (older WebViews, jsdom) stay
// at 0. rAF-throttled, same pattern as useNarrowViewport.
const [keyboardInset, setKeyboardInset] = useState(0)
useEffect(() => {
const vv = window.visualViewport
if (vv === null || vv === undefined) return
let frame: number | null = null
const measure = (): void => {
frame = null
const inset = Math.max(0, window.innerHeight - (vv.height + vv.offsetTop))
setKeyboardInset(inset > 1 ? Math.round(inset) : 0)
}
const onResize = (): void => { if (frame === null) frame = requestAnimationFrame(measure) }
vv.addEventListener('resize', onResize)
vv.addEventListener('scroll', onResize)
measure()
return () => {
vv.removeEventListener('resize', onResize)
vv.removeEventListener('scroll', onResize)
if (frame !== null) cancelAnimationFrame(frame)
}
}, [])
// Current conversation (the sessions list feed).
const sessionList = useSyncExternalStore(
useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]),
@@ -169,16 +204,23 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// Position compatibility mode (titleBarCompat pref): Windows frameless
// windows draw the native title bar (minimize/maximize/close) at the
// window's top-right corner, OVER the web content. When the user enables
// the pref, the body attribute lets sidebar.module.css drop the toggle
// cluster below the strip and push the right panel's content below it.
// The strip height is user-tunable (titleBarStripPx) and rides a CSS
// variable so the rules stay declarative. The attribute rides the
// window's top-right corner, OVER the web content. When enabled, the body
// attribute lets sidebar.module.css drop the toggle cluster below the
// strip and push the right panel's content below it. Auto-enabled in the
// win32 advanced desktop shell (the shell stamps the URL — see
// desktop-env.ts — and reserves a 32px overlay for the window controls);
// the manual pref and its strip height still win when the user sets them,
// and the manual switch only ever ADDS compat. The strip height rides a
// CSS variable so the rules stay declarative; the attribute rides the
// snapshot's prefs, so flipping the setting re-renders and re-applies
// immediately; the cleanup removes both on unmount/boundary swap so a
// crashed sidebar never leaves them behind.
const titleBarCompat = snapshot.prefs.titleBarCompat
const titleBarStrip = snapshot.prefs.titleBarStripPx
const desktopEnv = parseDesktopEnv()
const autoTitleBarCompat = desktopEnv.win32OverlayTop > 0
const titleBarCompat = snapshot.prefs.titleBarCompat || autoTitleBarCompat
const titleBarStrip = snapshot.prefs.titleBarCompat
? snapshot.prefs.titleBarStripPx
: desktopEnv.win32OverlayTop
useEffect(() => {
const root = document.documentElement
if (titleBarCompat) {
@@ -415,13 +457,28 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const watcher = new MutationObserver(locate)
const root = document.getElementById('root')
if (root !== null) watcher.observe(root, { childList: true })
// The layout push writes --dsh-sidebar-* on <html>. A HMR re-activation
// clears those variables on teardown and re-writes them on setup — and
// that is also the moment the shell may have re-created the center
// column under a REUSED #root child (React swaps nodes in place, so
// #root's childList never changes and the watcher above never fires).
// Watching <html>'s style attribute catches that re-sync: the push
// rewrite re-locates and re-measures, so the bottom panel recovers
// instead of staying hidden on a stale {0,0} center rect.
const htmlStyleWatcher = new MutationObserver(locate)
htmlStyleWatcher.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] })
return () => {
disposed = true
observer?.disconnect()
watcher.disconnect()
htmlStyleWatcher.disconnect()
centerColRef.current = null
}
}, [measureCenter])
// Opening the bottom panel re-runs the whole locate/measure chain: a
// panel opened before the center column was ever found must not stay
// invisible forever (the HMR recovery path depends on the observers
// above, this is the belt-and-braces retry for the open moment itself).
}, [measureCenter, state?.bottomOpen])
/**
* Bottom-panel first-expansion auto terminal: the FIRST time the user
@@ -486,10 +543,24 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const clampHeight = (height: number): number =>
Math.min(Math.max(BOTTOM_MIN, Math.round(height)), Math.max(BOTTOM_MIN, window.innerHeight - PANEL_MIN))
/** Single writer for the layout-push variables: the app shell gives up
* the panel's width/height while open (0 while collapsed) through
* layout.css's margins. Every size change — drag frames and committed
* state — flows through here so the push never forks between paths. */
const writeGeometry = (width: number, height: number): void => {
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
// The corner handle positions itself relative to the panel (CSS
// `bottom: calc(var(--dsh-sidebar-height) + 6px)`), so these two layout
// variables are all it needs — no viewport coordinates written here
// (issue #106: skins that inset the panels must not fight JS coords).
}
/** Apply a drag size to the DOM without touching React state or the store.
* The bottom panel's right edge tracks the right panel's left edge HERE
* too — React state only updates on release, so the inline right must be
* written directly or the bottom panel would lag the sidebar mid-drag. */
* written directly or the bottom panel would lag the sidebar mid-drag.
* The layout push rides the shared writer (writeGeometry). */
const applyDrag = (width: number, height: number): void => {
panelRef.current?.style.setProperty('width', `${width}px`)
bottomRef.current?.style.setProperty('height', `${height}px`)
@@ -498,12 +569,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// `width + detailsWidth` — derived from the measured column, keeping the
// drag write-only (no React re-render mid-drag).
bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRect.right) + (width - (state?.width ?? 0))}px`)
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
// The corner handle positions itself relative to the panel (CSS
// `bottom: calc(var(--dsh-sidebar-height) + 6px)`), so these two layout
// variables are all it needs — no viewport coordinates written here
// (issue #106: skins that inset the panels must not fight JS coords).
writeGeometry(width, height)
}
// Drags write at most once per frame: pointer events fire several times
@@ -535,23 +601,88 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
pendingDrag.current = null
}
/**
* Finalize a drag on pointer up: flush the LAST drag frame to the DOM
* synchronously, then commit the SAME clamped values to the store. A fast
* release cancels the rAF before it ran — without the flush the DOM would
* sit at the pre-drag size until React re-renders with the committed
* value, and a value that never made it into a move handler would never
* be applied at all. The measurement pause ends here too: the center
* column is re-measured BEFORE the committed re-render lands, so the
* bottom panel's React-rendered right edge already reflects the new
* width (otherwise the re-render would re-apply the stale rect — the
* bottom panel visibly jumps for one frame).
*/
const commitDrag = (
width: number,
height: number,
reduce: (state: SidebarState) => SidebarState,
): void => {
stopDragScheduling()
applyDrag(width, height)
draggingRef.current = false
measureCenter()
store.reduce(reduce)
}
/** Set once a drag's pointerup handler commits — premature capture loss
* (pointercancel / lostpointercapture without pointerup) must then be told
* apart from a normal release. */
const dragCommitted = useRef(false)
/** Abort a drag whose pointer stream was interrupted (pointercancel, or
* capture lost before pointerup): no pointerup will arrive, so without
* this the dragging state would stick true and center-column measurement
* would stay paused forever — the bottom panel freezes at stale edges and
* stops tracking sidebar/app-rail layout changes. Reverts the DOM and the
* layout variables to the store's committed sizes. */
const abortDrag = (reset: () => void): void => {
/**
* Abort a drag whose pointer stream was interrupted (pointercancel, or
* capture lost before pointerup): no pointerup will arrive, so without
* this the dragging state would stick true and center-column measurement
* would stay paused forever — the bottom panel freezes at stale edges and
* stops tracking sidebar/app-rail layout changes.
*
* A FAST release is the common trigger: browsers merge pointermove bursts,
* and an ultra-fast flick can cancel the stream before ANY move lands.
* The commit order is therefore: the LAST KNOWN dragged size (the rAF
* pending value) first, then the interrupting event's own pointer
* position (pointercancel / lostpointercapture still carry coordinates),
* and only a drag that produced neither (pure down+up at the same spot)
* reverts to the store's committed sizes.
*/
const abortDrag = (reset: () => void, event?: { clientX: number; clientY: number }): void => {
if (dragCommitted.current) return
stopDragScheduling()
applyDrag(
!narrow && state?.panelOpen === true ? Math.min(state?.width ?? 0, window.innerWidth) : 0,
!narrow && state?.bottomOpen === true ? Math.min(state.bottomHeight, window.innerHeight) : 0,
)
const pending = pendingDrag.current
let width: number | undefined
let height: number | undefined
if (pending !== null) {
width = pending.width
height = pending.height
} else if (event !== undefined) {
// No move ever landed: the cancel/lost-capture position is all we
// have — commit it (clamped) instead of rolling back the flick.
if (draggingWidth) {
width = clampWidth(widthDrag.current.startWidth + (widthDrag.current.startX - event.clientX))
height = state?.bottomOpen === true ? Math.min(state.bottomHeight, window.innerHeight) : 0
} else if (draggingBottom) {
width = Math.min(state?.width ?? 0, window.innerWidth)
height = clampHeight(bottomDrag.current.startHeight + (bottomDrag.current.startY - event.clientY))
} else if (draggingCorner) {
width = clampWidth(cornerDrag.current.startWidth + (cornerDrag.current.startX - event.clientX))
height = clampHeight(cornerDrag.current.startHeight + (cornerDrag.current.startY - event.clientY))
}
}
if (width !== undefined && height !== undefined) {
pendingDrag.current = null
if (dragFrame.current !== null) {
cancelAnimationFrame(dragFrame.current)
dragFrame.current = null
}
applyDrag(width, height)
draggingRef.current = false
measureCenter()
store.reduce(s => setBottomHeight(setWidth(s, width), height))
} else {
stopDragScheduling()
applyDrag(
!narrow && state?.panelOpen === true ? Math.min(state?.width ?? 0, window.innerWidth) : 0,
!narrow && state?.bottomOpen === true ? Math.min(state.bottomHeight, window.innerHeight) : 0,
)
}
reset()
}
@@ -569,8 +700,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const height = !narrow && snapshot.state?.bottomOpen === true
? Math.min(snapshot.state.bottomHeight, window.innerHeight)
: 0
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
writeGeometry(width, height)
// Unmount must release the push (issue #31): when the boundary swaps the
// whole sidebar after a render crash (or the plugin fiber is disposed /
// HMR), the CSS variables would otherwise stay on <html> and layout.css
@@ -660,19 +790,21 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
if (state === undefined || sessionId === undefined) {
return (
<div className={css.toggleCluster}>
{!narrow && (
<div data-dsh-panel-host>
<div className={css.toggleCluster}>
{!narrow && (
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
<IconPanelBottomOutline16 />
</button>
</Tooltip>
)}
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
<IconPanelBottomOutline16 />
<IconPanelRightOutline16 />
</button>
</Tooltip>
)}
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
<IconPanelRightOutline16 />
</button>
</Tooltip>
</div>
</div>
)
}
@@ -744,7 +876,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
)
return (
<>
<div data-dsh-panel-host>
{/*
The persistent toggle cluster at the top-right corner: the bottom
panel's button (bottom glyph) LEFT of the right panel's (side glyph).
@@ -793,7 +925,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
<div
ref={panelRef}
className={clsx(css.panel, !state.panelOpen && css.panelHidden)}
style={{ width: narrow ? '100vw' : Math.min(state.width, window.innerWidth) }}
style={{
width: narrow ? '100vw' : Math.min(state.width, window.innerWidth),
// Narrow drawer: keep the bottom-anchored sheet above the on-screen
// keyboard (visualViewport inset); desktop panels are full-height
// and unaffected.
bottom: narrow && keyboardInset > 0 ? `${keyboardInset}px` : undefined,
}}
data-dragging={anyDragging || undefined}
>
@@ -820,8 +958,16 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
dragCommitted.current = true
event.currentTarget.releasePointerCapture(event.pointerId)
const { startX, startWidth } = widthDrag.current
stopDragScheduling()
store.reduce(s => setWidth(s, startWidth + (startX - event.clientX)))
// A very fast release can merge/lose pointermove bursts:
// prefer the LAST KNOWN dragged width (already clamped by
// the move handler) over the up position, which may equal
// the down position when no move landed.
const pending = pendingDrag.current
const width = pending !== null
? pending.width
: clampWidth(startWidth + (startX - event.clientX))
const height = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0
commitDrag(width, height, s => setWidth(s, width))
setDraggingWidth(false)
}}
onPointerCancel={() => { abortDrag(() => setDraggingWidth(false)) }}
@@ -878,8 +1024,16 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
dragCommitted.current = true
event.currentTarget.releasePointerCapture(event.pointerId)
const { startX, startY, startWidth, startHeight } = cornerDrag.current
stopDragScheduling()
store.reduce(s => setBottomHeight(setWidth(s, startWidth + (startX - event.clientX)), startHeight + (startY - event.clientY)))
// Last known dragged size wins over the up position (fast
// releases can merge/lose pointermove bursts).
const pending = pendingDrag.current
const width = pending !== null
? pending.width
: clampWidth(startWidth + (startX - event.clientX))
const height = pending !== null
? pending.height
: clampHeight(startHeight + (startY - event.clientY))
commitDrag(width, height, s => setBottomHeight(setWidth(s, width), height))
setDraggingCorner(false)
}}
onPointerCancel={() => { abortDrag(() => setDraggingCorner(false)) }}
@@ -896,6 +1050,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
down like the right panel. On NARROW viewports it does not exist —
the bottom workbench lives inside the drawer (MobileWorkbench).
*/}
{/* The bottom panel only becomes VISIBLE once the center column is
measured: before that, `centerRect` is the {0,0} fallback and
`right` computes to the full viewport width — the panel (and its
overflow content) would flash full-width for a frame until the
first measurement lands. Rendering stays unconditional so the
mount/render chain (auto-terminal etc.) is never gated on
geometry. */}
{!narrow && (
<div
ref={bottomRef}
@@ -903,6 +1064,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
style={{
height: Math.min(state.bottomHeight, window.innerHeight),
left: centerRect.left,
// Keep the panel above the on-screen keyboard when the visual
// viewport shrinks (see the keyboardInset effect).
bottom: keyboardInset > 0 ? `${keyboardInset}px` : undefined,
// Direct from the center column's measured right edge: the bottom
// panel spans ONLY the center column, ending exactly at the
// details column's left edge (the details column sits between the
@@ -913,6 +1077,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// (the right panel's border-left alone is covered by this panel's
// fill — without it the corner looks cut off).
borderRight: state.panelOpen ? '1px solid var(--dsw-alias-border-l2)' : undefined,
// Unmeasured center column → keep the panel invisible (zero-size
// geometry would flash full-width overflow instead).
visibility: centerRect.right > 0 ? undefined : 'hidden',
}}
data-dragging={(draggingBottom || draggingCorner) || undefined}
@@ -938,8 +1105,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
dragCommitted.current = true
event.currentTarget.releasePointerCapture(event.pointerId)
const { startY, startHeight } = bottomDrag.current
stopDragScheduling()
store.reduce(s => setBottomHeight(s, startHeight + (startY - event.clientY)))
// Last known dragged height wins over the up position (fast
// releases can merge/lose pointermove bursts).
const pending = pendingDrag.current
const height = pending !== null
? pending.height
: clampHeight(startHeight + (startY - event.clientY))
commitDrag(Math.min(state.width, window.innerWidth), height, s => setBottomHeight(s, height))
setDraggingBottom(false)
}}
onPointerCancel={() => { abortDrag(() => setDraggingBottom(false)) }}
@@ -974,6 +1146,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
</div>
</div>
)}
</>
</div>
)
}