feat(sidebar): 桌面兼容三方案——自动检测(WCO 标准几何)/壳预设/自定义 CSS (#284)
* feat(sidebar): 桌面兼容三方案——自动检测(WCO 标准几何)/壳预设/自定义 CSS 核心只保留 Web 标准机制,壳专属适配全部下沉到用户空间(KISS): - prefs 新增 titleBarScheme(默认 auto)/titleBarPresetId/customCss,旧 titleBarCompat 布尔自动迁移(true→custom 保留 stripPx,false/缺失→auto) - wco.ts:navigator.windowControlsOverlay 反应式几何(geometrychange 实时更新; visible=false 的幽灵 API 视为缺失),实现 #257 - titlebar-strip.ts 唯一 strip 取值链:WCO → dsh-desktop-titlebar-inset 契约参数 → 预设 stripFor → 手动 px → 0;desktop-env.ts 移除 win32 32px 硬编码 - shell-presets.ts 数据驱动预设(准入:issue/PR 提及 + 100+ star;v1 仅 anywhere-labs DSH Desktop:darwin 20px / win32 无 WCO 时 32px),opt-in 手动 - 预设/自定义 CSS 注入(data-dsh-preset-css / data-dsh-custom-css 标签), chrome 补稳定寻址面 data-dsh-toggle-cluster / data-dsh-panel - 交互 chrome 统一 -webkit-app-region: no-drag(吸收 #111/#153) - layout.css 底栏推挤复合锚点([data-pane] 与 :has(> [data-slot]) 双保险, rc.8 实测前者失效、后者命中,吸收 #226 思路) - 设置 UI:位置兼容模式齿轮弹窗改三方案(自动检测/壳兼容方案/自定义方案) - 测试:wco/titlebar-strip/shell-presets 单测 + prefs 迁移 + e2e 5 场景 (保守 auto/WCO 反应式/预设/自定义 CSS/复合锚点),挂载门禁 12/12 全绿 核心 grep 无壳专属类名;为某壳做的兼容在另一壳会再坏——预设与自定义 CSS 是唯一出路(AGENTS.md §8 契约已更新)。 * refine(sidebar): 位置兼容模式改主行下拉四选项——自动检测/DSH官方Web/壳预设/自定义 按用户反馈定稿 UI: - 主行下拉:自动检测(默认)/ DSH官方Web(显式零适配,连 WCO 也强制 0)/ 各壳预设(命中环境带「已检测」后缀,仅提示)/ 自定义方案(保留齿轮弹窗: 下移距离 + 自定义 CSS) - 旧文档迁移:titleBarCompat=true 或 titleBarStripPx 非默认值(此前已有配置) → 默认落到 custom 方案保留用户数值;干净文档 → auto - prefs 新增 'web' 方案;strip 取值链最优先处理 web→0 - 测试:prefs 迁移(stripPx 48→custom / 40→auto)、web 强制 0、下拉渲染断言 - 单测 693 通过,挂载 e2e 12/12 全绿 * refine(sidebar): 方案下拉复用 SelectMenu 组件,不用原生 <select> - 抽出可复用 SelectMenu(primitives Menu:锚点按钮 + 下拉选项卡,支持 icon/desc/多选),SelectRow 改为其薄壳 - 位置兼容模式方案下拉改用 SelectMenu;选项带描述(自动检测/DSH官方Web/ 各壳预设(命中带「已检测」后缀)/自定义方案) - 移除原生 select 样式;单测 693 通过,挂载 e2e 12/12 全绿
This commit is contained in:
@@ -606,8 +606,15 @@ interface OpenTabSeed {
|
||||
- **面板表面**:右/底面板背景 = `var(--dsw-alias-bg-layer-1)`(通用卡片表面)。**绝不消费 `--dsw-specific-sidebar-fill`**——那是宿主左侧导航列专属令牌,皮肤系统按左导航语义覆盖它(dsh-web-ui 皮肤把它做成半透明玻璃或主题色,Aqua 设成 `transparent`),面板消费它会失去填充或与标签令牌冲突。皮肤要整体换面板表面:覆写 `--dsw-alias-bg-layer-1` 即可(dsh-web-ui 10 款皮肤都已覆盖,无需任何额外工作)。
|
||||
- **终端/编辑器表面**:经 `effectiveTokenValue` 读取 `--dsw-alias-bg-base`——`transparent` 与 alpha < 0.9 的半透明玻璃值(dsh-web-ui 皮肤用 rgba 0.16–0.7)一律回退不透明底色,文字永不叠在皮肤背景画上滚动(issue #90);≥ 0.9 的近不透明值(如皮肤作用域内 0.96 的瓷器玻璃)放行,皮肤仍能控制终端表面。
|
||||
- **根锚点**:宿主 div 带 `data-dsh-better-sidebar` 属性(append 到 `document.body`)。其内是**统一面板宿主层** `[data-dsh-panel-host]`(`position:fixed; inset:0; z-index:40; pointer-events:none`,v0.13.1+):面板/开关簇在其内 **absolute** 定位(层 inset:0 即视口坐标),免疫桌面套壳中间层 transform 对 fixed 含块的劫持;页面级 transform(罕见)触发 `data-dsh-panel-host-degraded` 降级同步。皮肤若要做作用域覆盖(deep-whale 的做法),限定在 `[data-dsh-better-sidebar]` 内即可,避免全局改写影响宿主。
|
||||
- **布局变量**(写在 `<html>` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`(面板几何;拖拽期间逐帧更新)。宿主推挤 = `#root` 的 `margin-right/width: calc(100% - var)`(v0.13.1+ 防桌面壳加性溢出)与 centerCol 的 `margin-bottom`;推挤锚点是 **`#root [data-dsh-frame] > [data-pane="conversation"]`**(稳定 data 属性,**禁止**退回 `nth-child` 位置锚定)。
|
||||
- **桌面信号**(v0.13.1+):DSH Desktop 壳在 URL 注入 `dsh-desktop-mode` / `dsh-desktop-platform`(preload 另暴露 `__DSH_DESKTOP_FILE_PATH__`);win32 `advanced` 壳右上角有 32px 窗口控制 overlay,插件自动启用标题栏兼容(`body[data-dsh-title-bar-compat]` + `--dsh-title-bar-strip: 32px`),手动 pref 可覆盖;`compatibility` 模式与无信号环境不避让。
|
||||
- **布局变量**(写在 `<html>` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`(面板几何;拖拽期间逐帧更新)。宿主推挤 = `#root` 的 `margin-right/width: calc(100% - var)`(v0.13.1+ 防桌面壳加性溢出)与 centerCol 的 `margin-bottom`;推挤锚点是**复合选择器**(同一元素双保险,禁止退回 `nth-child` 位置锚定):`#root [data-dsh-frame] > [data-pane="conversation"]` **与** `#root :has(> [data-slot="conversation"])`(0.1.x 命名 / rc.8 时代命名,live 页面实测同元素;`drag-layout.e2e.ts` 断言两者同元素)。
|
||||
- **桌面信号与标题栏兼容**(v0.14.1+ 四选项模型,取代旧"win32 advanced 自动 32px"硬编码):
|
||||
- 壳信号(只读报告,不自动触发任何修改):URL `dsh-desktop-mode` / `dsh-desktop-platform`(DSH Desktop 壳注入;preload 另暴露 `__DSH_DESKTOP_FILE_PATH__`);可选契约参数 `dsh-desktop-titlebar-inset`(壳声明自己预留的顶栏像素,0–120,clamp;`parseDesktopEnv().titlebarInset`)。
|
||||
- **strip 取值链**(`src/client/titlebar-strip.ts` 唯一决策点,纯函数):⓪ `web` 方案强制 0(显式 DSH官方Web,连 WCO 也不适用);① `navigator.windowControlsOverlay` 标准 API 真实几何(`src/client/wco.ts`,反应式订阅 `geometrychange`,最大化/还原实时更新——**即使为 0 也权威**,`visible=false` 的幽灵 API 视为缺失,覆盖 #257 的 32/36px 差异);② URL `dsh-desktop-titlebar-inset`;③ 壳预设 `stripFor`(仅 `preset` 方案);④ 手动 `titleBarStripPx`(仅 `custom` 方案);⑤ 0(网页零修改)。结果驱动 `body[data-dsh-title-bar-compat]` + `--dsh-title-bar-strip`(语义与旧布尔一致)。
|
||||
- **四选项设置**(`SidebarPrefs.titleBarScheme`,设置页主行**下拉**:`auto` 默认 / `web` / `preset` / `custom`):`auto` = 只信标准信号(WCO),壳 stamp 不触发任何修改——**"为某壳做的兼容在另一个壳会再坏",核心不做壳专属分支**;`web` = 显式「DSH官方Web」,任何情况强制 0;`preset` = 数据驱动的内置壳预设(`src/client/shell-presets.ts`,准入规则:issue/PR 中被提及且 GitHub ⭐>100;v1 仅 anywhere-labs DSH Desktop:darwin advanced 20px、win32 无 WCO 时 32px 兜底;下拉选项命中环境时带「已检测」后缀,仅提示、绝不自动启用);`custom` = 用户自定义 CSS + 手动 strip px(下拉选中后出现齿轮按钮,弹窗含下移距离与 CSS 输入)。
|
||||
- **旧文档迁移**:schema 中 `titleBarScheme` 无默认值(缺失才可迁移);旧文档**已有值**(`titleBarCompat === true` 或 `titleBarStripPx` 非默认 40)→ 迁移到 `custom` 方案保留用户数值;干净文档 → `auto`。
|
||||
- **用户空间 CSS**:预设 css(`<style data-dsh-preset-css>`)与自定义 css(`<style data-dsh-custom-css>`)经 `Sidebar.tsx` effect 注入到 `document.head` 末尾(后写胜出;覆盖 JS 内联变量需 `!important`),fiber 卸载/变更即移除。稳定寻址面(皮肤/预设/自定义 CSS 共用):`[data-dsh-toggle-cluster]`、`[data-dsh-panel]`(右面板)、`[data-dsh-bottom-panel]`(底面板)。
|
||||
- **拖拽区退出**:插件交互 chrome(`.toggleCluster` / `.toggleButton` / `.tabBar`)统一 `-webkit-app-region: no-drag`——无边框壳的顶部拖拽带会吞点击(#103/#111),该属性在普通浏览器与无拖拽区壳中惰性无害。
|
||||
- `compatibility` 模式与无信号环境不避让。
|
||||
- **z-index**:面板宿主层 40、折叠按钮簇 45(角手柄在面板内层叠,z-index 2 仅面板内有效)——全部低于 DSH 浮层栈(100/1000+),任何浮层天然盖住侧边栏。
|
||||
|
||||
### 8.2 注意事项
|
||||
|
||||
@@ -57,7 +57,8 @@
|
||||
|
||||
**✨ 新功能**
|
||||
|
||||
- 🖼️ **统一面板宿主注入重构**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):面板/开关簇迁入 `[data-dsh-panel-host]` 固定含块层(`fixed inset-0 z-40`),免疫桌面套壳中间层 transform 对 fixed 含块的劫持;挂载自检(页面级 transform → `data-dsh-panel-host-degraded` 降级同步,按未修正几何判定、祖先变换消失才退出);推挤锚点改 `#root [data-dsh-frame] > [data-pane="conversation"]` + `#root` calc 宽度防桌面壳加性溢出;chunk 激活重验证(HEAD+ETag 保留未变 chunk,5s 超时兜底 fail-open);桌面信号自动探测(win32 advanced 标题栏兼容 32px 避让,手动 pref 可覆盖);`visualViewport` 键盘 inset + `env(safe-area-inset-*)` 移动端适配
|
||||
- 🧩 **桌面兼容四选项**:位置兼容模式改为**主行下拉**——**自动检测**(默认,保守:仅使用标准的 Window Controls Overlay 几何,32/36px 等各壳差异自动跟随、最大化/还原实时更新,网页环境零修改)/ **DSH官方Web**(显式零适配)/ **壳兼容方案**(内置预设,手动启用;只收录 issue/PR 中出现过且 100+ star 的壳,命中环境带「已检测」提示)/ **自定义方案**(自定义 CSS + 下移距离,保留设置按钮)。旧版本已有兼容配置的用户自动落到自定义方案。核心不再为具体壳写适配分支;交互控件统一退出桌面拖拽区(`no-drag`,吸收 #111/#153);推挤锚点复合选择器加固(`[data-pane]` 与 `:has(> [data-slot])` 双保险)
|
||||
- 🖼️ **统一面板宿主注入重构**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):面板/开关簇迁入 `[data-dsh-panel-host]` 固定含块层(`fixed inset-0 z-40`),免疫桌面套壳中间层 transform 对 fixed 含块的劫持;挂载自检(页面级 transform → `data-dsh-panel-host-degraded` 降级同步,按未修正几何判定、祖先变换消失才退出);推挤锚点改 `#root [data-dsh-frame] > [data-pane="conversation"]` + `#root` calc 宽度防桌面壳加性溢出;chunk 激活重验证(HEAD+ETag 保留未变 chunk,5s 超时兜底 fail-open);`visualViewport` 键盘 inset + `env(safe-area-inset-*)` 移动端适配
|
||||
- 📂 **文件打开方式默认独立**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):`editorExplorer` 默认从「合并」改为「独立」——新会话树点击 / 打开文件按路径**新开**文件 tab,无路径窗口即纯资源管理器;合并模式保留为可选手动开启
|
||||
- 🖥️ **终端 shell / shellArgs 设置页可配**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):终端卡齿轮二级页面新增「Shell 路径」「Shell 参数」两行配置(此前只能通过 `cordis.patch.yml` 配置)——设置页写入后对**之后打开的** UI 终端与模型终端(`terminal_create`)即时生效;留空保持 yaml → `$SHELL` / 登录 shell / `powershell.exe` 的既有解析顺序
|
||||
- 🏷️ **设置页版本徽标**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):侧边卡片设置页顶部新增 `DSH-better-sidebar v0.14.0` 身份徽标(版本与服务实例同步,由测试守护)
|
||||
|
||||
+2
-1
@@ -56,7 +56,8 @@
|
||||
|
||||
**✨ New features**
|
||||
|
||||
- 🖼️ **Unified panel-host injection refactor** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): panels/toggle clusters moved into a `[data-dsh-panel-host]` fixed containing block (`fixed inset-0 z-40`), immune to desktop-shell intermediate transforms hijacking `fixed`; mount self-check (page-level transform → `data-dsh-panel-host-degraded` degraded sync, judged on uncorrected geometry, exits only when the ancestor transform is gone); push anchor switched to `#root [data-dsh-frame] > [data-pane="conversation"]` + `#root` calc width against desktop-shell additive overflow; chunk revalidation on activation (HEAD+ETag keeps unchanged chunks, 5s timeout fails open); desktop signal auto-detection (win32 advanced title-bar compat 32px avoidance, manual pref overrides); `visualViewport` keyboard inset + `env(safe-area-inset-*)` mobile adaptation
|
||||
- 🧩 **Desktop compatibility in four options**: "Position compatibility mode" is now a main-row dropdown — **Auto-detect** (default, conservative: only the standard Window Controls Overlay geometry contributes; real 32/36px caption-overlay heights per shell, live on maximize/restore; zero modification on plain web) / **DSH official web** (explicitly no adaptation) / **Shell preset** (built-in, opt-in; only shells that appeared in this repo's issues/PRs with 100+ stars, "detected" badge when the environment matches) / **Custom** (free-form CSS + shift distance, keeps the settings button). Documents that already carried compatibility values migrate to the custom scheme. The core no longer branches per shell; interactive chrome opts out of desktop drag regions (`no-drag`, absorbing #111/#153); the bottom-push anchor is a composite selector (`[data-pane]` and `:has(> [data-slot])` — same element, double protection)
|
||||
- 🖼️ **Unified panel-host injection refactor** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): panels/toggle clusters moved into a `[data-dsh-panel-host]` fixed containing block (`fixed inset-0 z-40`), immune to desktop-shell intermediate transforms hijacking `fixed`; mount self-check (page-level transform → `data-dsh-panel-host-degraded` degraded sync, judged on uncorrected geometry, exits only when the ancestor transform is gone); push anchor switched to `#root [data-dsh-frame] > [data-pane="conversation"]` + `#root` calc width against desktop-shell additive overflow; chunk revalidation on activation (HEAD+ETag keeps unchanged chunks, 5s timeout fails open); `visualViewport` keyboard inset + `env(safe-area-inset-*)` mobile adaptation
|
||||
- 📂 **Separate file windows by default** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): `editorExplorer` now defaults to **separate** — tree clicks / file opens create a new tab per path and the path-less window is a pure file manager; merged mode stays available as an opt-in
|
||||
- 🖥️ **Terminal shell / shellArgs configurable from the settings page** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): the terminal card's gear popup gains "Shell path" and "Shell arguments" rows (previously yaml-only via `cordis.patch.yml`) — saved values take effect immediately for terminals opened afterwards (UI terminals and model `terminal_create` alike); empty keeps the existing yaml → `$SHELL` / login shell / `powershell.exe` resolution order
|
||||
- 🏷️ **Version badge on the settings page** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): the side-card settings section now opens with a `DSH-better-sidebar v0.14.0` identity badge (version synced with the service instance, test-guarded)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# 桌面端兼容统一方案:四选项下拉(自动检测 / DSH官方Web / 壳预设 / 自定义 CSS)
|
||||
|
||||
> 2026-08-20 · 对应分支 `feat/desktop-scheme-settings` · 关联 issue #257、#208、#103/#111;外部 PR #82/#89/#111/#153/#226/#243 处置见文末。
|
||||
|
||||
## 背景与问题
|
||||
|
||||
better-sidebar 的桌面端适配历史上是"每壳一条补丁":右上角开关簇与原生标题栏按钮重叠(#87/#101)、点击被拖拽区吞掉(#103/#111)、推挤锚点随 DSH DOM 版本漂移(#208/#226/#262)、WCO 高度硬编码 32px 与实际不符(#257,Electron 版本间 32/36px 不一)。这些补丁全部针对**具体某个壳的 DOM/类名/数值**,换一个壳(Tauri、Pake、新壳)就失效或出错。
|
||||
|
||||
**全仓证据盘点**(121 issues + 159 PRs 全量 + 三个候选壳克隆取证):
|
||||
|
||||
| 壳 | ⭐ | issue/PR 提及 | 形态 | 需要什么 |
|
||||
|---|---|---|---|---|
|
||||
| anywhere-labs/deepseek-harness-desktop | 16.3K | ✅ 大量 | Electron;advanced: win32 WCO 32px / darwin caption 20px;compat 原生框 | win32 已被标准 WCO 覆盖;darwin 20px 让位 |
|
||||
| dataelement/dsh-desktop | 1.3K | ❌ 零提及 | Electron;win32 WCO 36px | 已被标准 WCO 覆盖 |
|
||||
| Deepseek-Harness-EAC | 1.0K | ❌ 零提及 | Electron Win-only;自绘 36px 玻璃栏(私有标记 `data-dsh-title-bar-height`) | 无 WCO,需 36px 让位(文档示例) |
|
||||
| hairyf Tauri 版 | 715 | ✅ pr#229 | Tauri2;44px 导航栏在 iframe **之上** | **无冲突**,无需适配 |
|
||||
|
||||
平台分布结论:兼容问题**不是 Windows 独有**(macOS 6 条、Web 3 条),但当前 main 上未闭环的 chrome 兼容基本就是 **Windows 顶栏**(WCO 几何 + 通用 no-drag 兜底);macOS 的坏 bug(#25/#208)已修复。
|
||||
|
||||
## 决策
|
||||
|
||||
1. **核心只留 Web 标准机制**:WCO(`navigator.windowControlsOverlay` + `geometrychange`)是唯一"不是适配、是标准"的信号;`-webkit-app-region: no-drag` 是标准属性(普通浏览器/无拖拽区壳惰性无害)。核心代码 grep 不到任何壳类名。
|
||||
2. **壳专属适配进用户空间**:「位置兼容模式」升级为**主行下拉**四选项:**自动检测(默认,保守,web 零修改)/ DSH官方Web(显式零适配)/ 壳兼容方案(内置预设,opt-in)/ 自定义方案(自由 CSS + 下移 px,保留齿轮弹窗)**。
|
||||
3. **预设准入规则**:issue/PR 中被提及(链接或明确名称)且 GitHub ⭐>100 的壳;v1 仅 anywhere-labs(唯一同时满足者)。预设只做**标题栏 strip 让位**,不做布局推挤/壳类名(尊重"不特意兼容 DSH Desktop 高级模式")。Tauri 无冲突只写文档;EAC 未提及但提供 custom CSS 示例。
|
||||
4. 不向 anywhere-labs 提跨仓库 PR;不修改 DSH 源码。
|
||||
|
||||
## 实施
|
||||
|
||||
### prefs 模型(`src/prefs-shared.ts` / `src/config.ts` / `src/client/prefs.ts`)
|
||||
|
||||
- 新增 `titleBarScheme: 'auto' | 'web' | 'preset' | 'custom'`(默认 `'auto'`)、`titleBarPresetId: string`、`customCss: string`。`web` = 显式「DSH官方Web」,取值链最优先强制 0(连 WCO 也不适用)。
|
||||
- schema 中三个新字段**无 default**(schemastery 验证:缺失字段不出现在解析结果里)→ `parsePrefs` 在字段缺失时由旧 `titleBarCompat` 派生:`true` → `custom`(保留 `titleBarStripPx`);`false`/缺失 → `auto`。写路径镜像写旧字段(`titleBarCompat = scheme !== 'auto'`)供降级。
|
||||
|
||||
### 标准件(`src/client/wco.ts` + `src/client/titlebar-strip.ts`)
|
||||
|
||||
- `wco.ts`:模块级反应式 store(subscribe/getSnapshot,`useSyncExternalStore` 消费),特性检测 `navigator.windowControlsOverlay`,订阅 `geometrychange` 重读矩形(最大化/还原实时更新);最后订阅者退订时解绑原生监听;`setWcoSourceForTests` 注入测试源。API 抛错视为 present+0(绝不崩布局)。
|
||||
- `titlebar-strip.ts`(唯一决策点,纯函数):**⓪ `web` 方案强制 0 → ① WCO 真实高度(权威,0 也权威;`visible=false` 幽灵 API 视为缺失)→ ② URL `dsh-desktop-titlebar-inset`(0–120 clamp)→ ③ 预设 `stripFor`(仅 preset 方案)→ ④ 手动 `titleBarStripPx`(仅 custom 方案)→ ⑤ 0**。结果驱动 `body[data-dsh-title-bar-compat]` + `--dsh-title-bar-strip`(CSS 契约不变)。
|
||||
- `desktop-env.ts`:只做环境报告(desktop/mode/platform/titlebarInset),SSR 安全;移除旧 `win32OverlayTop` 硬编码。
|
||||
|
||||
### 预设与用户 CSS(`src/client/shell-presets.ts` + `Sidebar.tsx` effect)
|
||||
|
||||
- `ShellPreset { id; title; desc; stripFor?; css?; detect? }`;v1 一条 `dsh-desktop`:darwin advanced 20px、win32 advanced 32px(无 WCO 兜底)、其余 undefined;`detect` 仅用于设置页「已检测」徽标。
|
||||
- 注入:预设 css → `<style data-dsh-preset-css="<id>">`;自定义 css → `<style data-dsh-custom-css="custom">`;追加到 `document.head` 末尾(后写胜出),fiber 卸载/变更即移除。
|
||||
- 稳定寻址面:`[data-dsh-toggle-cluster]`、`[data-dsh-panel]`、`[data-dsh-bottom-panel]` data 属性。
|
||||
|
||||
### no-drag 兜底(`sidebar.module.css`)
|
||||
|
||||
`.toggleCluster` / `.toggleButton` / `.tabBar` 统一 `-webkit-app-region: no-drag`(吸收 #111/#153,补 #103 插件侧缺口)。
|
||||
|
||||
### Web 推挤锚点加固(`layout.css`)
|
||||
|
||||
复合选择器(同元素双保险):`#root [data-dsh-frame] > [data-pane="conversation"]` 与 `#root :has(> [data-slot="conversation"])`;拖拽过渡/prefers-reduced-motion 同步;`drag-layout.e2e.ts` 新增两选择器同元素断言。
|
||||
|
||||
### 设置 UI(`SideCardSection.tsx` + locales)
|
||||
|
||||
「位置兼容模式」行改为**主行下拉**:自动检测(默认)/ DSH官方Web / 各壳预设(命中环境带「已检测」后缀,仅提示)/ 自定义方案(选中后行内出现齿轮,弹窗 = 下移距离 + CSS textarea(Cmd/Ctrl+Enter 提交))。
|
||||
|
||||
### 测试
|
||||
|
||||
- 单测:`wco.spec.ts`(7 例:检测/读取/取整/geometrychange 通知/抛错降级/换源)、`titlebar-strip.spec.ts`(取值链 5 例)、`shell-presets.spec.ts`(注册表完整性 + dsh-desktop strip 值)、`desktop-env.spec.ts`(重写:inset 参数 clamp + 移除 win32 硬编码)、`prefs.spec.ts`(新字段 + 旧字段迁移)、`plugin-shape.spec.ts`(schema 无默认值语义)、`smoke.spec.ts`(解析结果不含新字段)。
|
||||
- e2e(`mount.e2e.ts` 重写桌面场景):保守 auto(stamp 不触发修改 + data 属性 + no-drag 规则存在)、WCO mock 驱动 strip 且 geometrychange 反应、预设注入(settings.update 写入 scheme=preset → 32px + style 标签)、自定义 CSS 注入(marker 生效);`drag-layout.e2e.ts` 复合锚点。
|
||||
|
||||
## 验证
|
||||
|
||||
- `pnpm typecheck` / `pnpm test`(691 passed)/ `pnpm test:mount` 全绿。
|
||||
- 普通浏览器零行为变化(auto 无 WCO → 无 strip)。
|
||||
- Electron WCO win32(mock 实测):strip=真实高度(32/36),最大化/还原跟随。
|
||||
- anywhere-labs darwin advanced:启用预设后 20px 让位。
|
||||
- 核心代码 grep 无壳类名(`.dshDesktopFrame` 等仅存在于预设数据字符串与测试断言之外——v1 预设无 CSS,纯 strip 数据)。
|
||||
|
||||
## 实施偏差记录
|
||||
|
||||
- 无。计划与实施一致;仅补充:schema 新字段必须无 default 才能让旧文档迁移(否则 settings 服务会把 default 填进解析结果、吞掉迁移),`plugin-shape.spec.ts` 断言了这一点。
|
||||
|
||||
## 存量 PR/issue 处置(合并后执行,均含哲学声明)
|
||||
|
||||
- 实现后关闭:#257(WCO 标准件)。
|
||||
- 关闭:#111/#153(no-drag 吸收)、#226(复合锚点吸收,main 已修)、#243(布局推挤不采纳;标题栏部分进 dsh-desktop 预设)、#108(#232 已吸收 + WCO)、#82/#89/#138(不采纳,指向自定义 CSS/预设路径)。
|
||||
- 回复:#208(已修+回归)、#221(自定义 CSS 可微调)、#233(git.ts windowsHide 独立问题)、#167/#240(非 chrome 范畴/文档已有)。
|
||||
@@ -462,6 +462,28 @@
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* The multi-line custom-CSS input (scheme `custom`): monospace, generous
|
||||
height, resizable — same surface tokens as the typed inputs. */
|
||||
.cssTextArea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 120px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-family: var(--ds-font-family-code, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.cssTextArea:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Secondary settings popup: a wider dialog than the primitive default (the
|
||||
dialog portals to document.body; the doubled class keeps the width rule
|
||||
above the primitive's .dialog regardless of stylesheet order). */
|
||||
|
||||
+205
-68
@@ -38,7 +38,7 @@
|
||||
* shows the wire error inline — a broken settings surface never crashes the
|
||||
* shell.
|
||||
*/
|
||||
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Fragment, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
IconCheckOutline16,
|
||||
IconChevronDownOutline14,
|
||||
@@ -59,11 +59,14 @@ import {
|
||||
WIDTH_PERCENT_MAX,
|
||||
WIDTH_PERCENT_MIN,
|
||||
type SidebarPrefs,
|
||||
type TitleBarScheme,
|
||||
} from '../prefs-shared.ts'
|
||||
import { api } from './api.ts'
|
||||
import { parsePrefs } from './prefs.ts'
|
||||
import { AddPluginModal, type PluginKind } from './add-plugin-modal.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import { parseDesktopEnv } from './desktop-env.ts'
|
||||
import { getShellPreset, getShellPresets } from './shell-presets.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import type {
|
||||
BetterSidebarService,
|
||||
@@ -109,6 +112,17 @@ function tabOrder(a: TabDescriptor, b: TabDescriptor): number {
|
||||
return (a.order ?? 100) - (b.order ?? 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* The scheme dropdown's current value: the plain scheme, or `preset:<id>`
|
||||
* while a preset is active. Falls back to `auto` when the stored preset id
|
||||
* is no longer registered (the strip resolves to 0 then anyway).
|
||||
*/
|
||||
function titleBarSchemeValue(prefs: SidebarPrefs): string {
|
||||
if (prefs.titleBarScheme !== 'preset') return prefs.titleBarScheme
|
||||
const preset = getShellPreset(prefs.titleBarPresetId)
|
||||
return preset !== undefined ? `preset:${preset.id}` : 'auto'
|
||||
}
|
||||
|
||||
/** Viewer inventory order: priority desc (the catch-all `code` comes last). */
|
||||
function viewerOrder(a: FileViewerDescriptor, b: FileViewerDescriptor): number {
|
||||
return (b.priority ?? 0) - (a.priority ?? 0)
|
||||
@@ -318,24 +332,60 @@ function TypedRow(props: {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/**
|
||||
* The multi-line custom-CSS input (scheme `custom`): a monospace textarea
|
||||
* whose draft is local state, committed on blur or Cmd/Ctrl+Enter through
|
||||
* the parent's handler. Keyed by the stored value so an external commit
|
||||
* remounts it with the canonical text (same pattern as TypedRow).
|
||||
*/
|
||||
function CssDraft(props: {
|
||||
value: string
|
||||
onCommit: (raw: string) => void
|
||||
label: string
|
||||
placeholder?: string
|
||||
}) {
|
||||
const { value, onCommit, label, placeholder } = props
|
||||
const [draft, setDraft] = useState(value)
|
||||
return (
|
||||
<textarea
|
||||
className={css.cssTextArea}
|
||||
rows={6}
|
||||
value={draft}
|
||||
placeholder={placeholder}
|
||||
aria-label={label}
|
||||
spellCheck={false}
|
||||
onChange={event => { setDraft(event.currentTarget.value) }}
|
||||
onBlur={() => { onCommit(draft) }}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) event.currentTarget.blur()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One select row: a dropdown over the toggle's declared `options`. When any
|
||||
* option carries an icon, the dropdown renders big-icon option cards (icon +
|
||||
* title + desc) and the closed anchor shows the selected option's icon as
|
||||
* well; without icons both are a single line of text. Single-pick commits the
|
||||
* option's value and closes; `multi` toggles membership, commits the picked
|
||||
* values as an array (in options order), and stays open.
|
||||
* The reusable dropdown — the primitives Menu, NOT a native <select>: a
|
||||
* closed anchor button (picked option text + chevron) opening one Menu item
|
||||
* per option (big-icon cards when any option carries an icon). Single-pick
|
||||
* commits the option's value and closes; `multi` toggles membership and
|
||||
* commits the picked values as an array (in options order), staying open.
|
||||
* Shared by the declarative select rows (SelectRow) and the title-bar
|
||||
* scheme dropdown on the General row.
|
||||
*/
|
||||
function SelectRow(props: {
|
||||
toggle: SidebarSettingToggle
|
||||
title: string
|
||||
function SelectMenu(props: {
|
||||
label: string
|
||||
value: unknown
|
||||
onSelectValue?: (toggle: SidebarSettingToggle, next: unknown) => void
|
||||
options: readonly {
|
||||
value: string | number | boolean
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
}[]
|
||||
multi?: boolean
|
||||
onSelect: (next: unknown) => void
|
||||
placeholder?: string
|
||||
}) {
|
||||
const { toggle, title, value, onSelectValue } = props
|
||||
const options = toggle.options ?? []
|
||||
const multi = toggle.multi === true
|
||||
const { label, value, options, multi, onSelect, placeholder } = props
|
||||
const [open, setOpen] = useState(false)
|
||||
const hasIcons = options.some(option => option.icon !== undefined)
|
||||
const picked: readonly unknown[] = multi ? (Array.isArray(value) ? value : []) : [value]
|
||||
@@ -346,7 +396,7 @@ function SelectRow(props: {
|
||||
const option = options[index]
|
||||
if (option === undefined) return
|
||||
if (!multi) {
|
||||
onSelectValue?.(toggle, option.value)
|
||||
onSelect(option.value)
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
@@ -355,14 +405,14 @@ function SelectRow(props: {
|
||||
if (at >= 0) current.splice(at, 1)
|
||||
else current.push(option.value)
|
||||
// Stable wire order: follow the declared options order, not pick order.
|
||||
onSelectValue?.(toggle, options.filter(o => current.includes(o.value)).map(o => o.value))
|
||||
onSelect(options.filter(o => current.includes(o.value)).map(o => o.value))
|
||||
}
|
||||
|
||||
const anchor = (
|
||||
<button
|
||||
type="button"
|
||||
className={css.selectAnchor}
|
||||
aria-label={title}
|
||||
aria-label={label}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
onClick={() => { setOpen(now => !now) }}
|
||||
@@ -371,12 +421,55 @@ function SelectRow(props: {
|
||||
<span className={css.selectAnchorIcon}>{iconOf(selected[0].icon, 16)}</span>
|
||||
)}
|
||||
<span className={css.selectAnchorText}>
|
||||
{selected.length === 0 ? '—' : selected.map(option => textOf(option.title)).join(', ')}
|
||||
{selected.length === 0 ? (placeholder ?? '—') : selected.map(option => textOf(option.title)).join(', ')}
|
||||
</span>
|
||||
<IconChevronDownOutline14 size={12} />
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
anchor={anchor}
|
||||
items={options.map((option, index) => ({
|
||||
id: String(index),
|
||||
label: hasIcons
|
||||
? (
|
||||
<span className={css.selectOption}>
|
||||
<span className={css.selectOptionIcon}>{iconOf(option.icon, 24)}</span>
|
||||
<span className={css.selectOptionText}>
|
||||
<span className={css.title}>{textOf(option.title)}</span>
|
||||
{textOf(option.desc) !== '' && <span className={css.desc}>{textOf(option.desc)}</span>}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
: textOf(option.title),
|
||||
}))}
|
||||
selectedId={!multi && selected[0] !== undefined ? String(options.indexOf(selected[0])) : undefined}
|
||||
selectedIds={multi ? selected.map(option => String(options.indexOf(option))) : undefined}
|
||||
onSelect={(id) => { pick(Number(id)) }}
|
||||
onClose={() => { setOpen(false) }}
|
||||
portal
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One select row: a dropdown over the toggle's declared `options` (the
|
||||
* shared SelectMenu). When any option carries an icon, the dropdown renders
|
||||
* big-icon option cards (icon + title + desc) and the closed anchor shows
|
||||
* the selected option's icon as well; without icons both are a single line
|
||||
* of text. Single-pick commits the option's value and closes; `multi`
|
||||
* toggles membership, commits the picked values as an array (in options
|
||||
* order), and stays open.
|
||||
*/
|
||||
function SelectRow(props: {
|
||||
toggle: SidebarSettingToggle
|
||||
title: string
|
||||
value: unknown
|
||||
onSelectValue?: (toggle: SidebarSettingToggle, next: unknown) => void
|
||||
}) {
|
||||
const { toggle, title, value, onSelectValue } = props
|
||||
return (
|
||||
<div className={css.popupRow}>
|
||||
<span className={css.rowText}>
|
||||
@@ -384,28 +477,12 @@ function SelectRow(props: {
|
||||
{textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>}
|
||||
</span>
|
||||
<span className={css.control}>
|
||||
<Menu
|
||||
open={open}
|
||||
anchor={anchor}
|
||||
items={options.map((option, index) => ({
|
||||
id: String(index),
|
||||
label: hasIcons
|
||||
? (
|
||||
<span className={css.selectOption}>
|
||||
<span className={css.selectOptionIcon}>{iconOf(option.icon, 24)}</span>
|
||||
<span className={css.selectOptionText}>
|
||||
<span className={css.title}>{textOf(option.title)}</span>
|
||||
{textOf(option.desc) !== '' && <span className={css.desc}>{textOf(option.desc)}</span>}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
: textOf(option.title),
|
||||
}))}
|
||||
selectedId={!multi && selected[0] !== undefined ? String(options.indexOf(selected[0])) : undefined}
|
||||
selectedIds={multi ? selected.map(option => String(options.indexOf(option))) : undefined}
|
||||
onSelect={(id) => { pick(Number(id)) }}
|
||||
onClose={() => { setOpen(false) }}
|
||||
portal
|
||||
<SelectMenu
|
||||
label={title}
|
||||
value={value}
|
||||
options={toggle.options ?? []}
|
||||
multi={toggle.multi === true}
|
||||
onSelect={(next) => { onSelectValue?.(toggle, next) }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
@@ -498,6 +575,10 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
const [settingsFor, setSettingsFor] = useState<TabDescriptor | FileViewerDescriptor | null>(null)
|
||||
// Whether the position-compat strip popup (the gear on the 常规 row) is open.
|
||||
const [stripSettingsOpen, setStripSettingsOpen] = useState(false)
|
||||
// The parsed desktop environment (URL stamps — see desktop-env.ts). Used
|
||||
// ONLY to badge matching presets in the scheme dropdown ("已检测");
|
||||
// nothing is auto-applied.
|
||||
const detectedEnv = useMemo(() => parseDesktopEnv(), [])
|
||||
// Whether the "add plugin" modal (a dashed card at the end of the
|
||||
// 侧边栏内容 / 文件预览 grids) is open, and for which extension point
|
||||
// (null = closed).
|
||||
@@ -635,6 +716,38 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
return raw
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the title-bar / shell compatibility scheme. Mirrors the legacy
|
||||
* `titleBarCompat` flag (true = anything but the conservative auto) so
|
||||
* documents stay readable by older plugin versions.
|
||||
*/
|
||||
/**
|
||||
* Pick the title-bar / shell compatibility scheme from the dropdown. The
|
||||
* option values are `auto` | `web` | `custom` | `preset:<id>`; selecting
|
||||
* a preset stores both the scheme and its id. Mirrors the legacy
|
||||
* `titleBarCompat` flag (true for preset/custom) so documents stay
|
||||
* readable by older plugin versions.
|
||||
*/
|
||||
const onSchemeSelect = (value: unknown): void => {
|
||||
if (typeof value !== 'string') return
|
||||
if (value === 'auto' || value === 'web' || value === 'custom') {
|
||||
applyPref({ titleBarScheme: value, titleBarCompat: value === 'custom' })
|
||||
return
|
||||
}
|
||||
if (value.startsWith('preset:') && getShellPreset(value.slice('preset:'.length)) !== undefined) {
|
||||
applyPref({
|
||||
titleBarScheme: 'preset',
|
||||
titleBarPresetId: value.slice('preset:'.length),
|
||||
titleBarCompat: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Commit the free-form custom CSS (scheme `custom`). */
|
||||
const commitCustomCss = (raw: string): void => {
|
||||
applyPref({ customCss: raw })
|
||||
}
|
||||
|
||||
/** Persist one plugin-owned setting of one descriptor (merged into the pluginSettings blob). */
|
||||
const applyPluginSetting = (descriptorId: string, key: string, value: unknown): void => {
|
||||
applyPref({ pluginSettings: mergePluginSetting(optimisticRef.current.pluginSettings, descriptorId, key, value) })
|
||||
@@ -800,12 +913,31 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
</span>
|
||||
<span className={css.control}>
|
||||
{/*
|
||||
The position-compat row's gear (same popup pattern as the
|
||||
feature cards): opens a Modal with the strip-height number row.
|
||||
Hidden while the mode is off — its related setting is dormant
|
||||
then (the feature-card convention).
|
||||
The scheme dropdown (the shared SelectMenu — NOT a native
|
||||
select): 自动检测 (default) / DSH官方Web / 各壳兼容方案 /
|
||||
自定义方案. Matching presets carry a 「已检测」 desc badge
|
||||
(suggestion only). The 自定义方案 row keeps its gear (the
|
||||
popup with the shift distance + custom CSS) — the other
|
||||
schemes need no further settings.
|
||||
*/}
|
||||
{prefs.titleBarCompat && (
|
||||
<SelectMenu
|
||||
label={t('settingsTitleBarTitle')}
|
||||
value={titleBarSchemeValue(prefs)}
|
||||
options={[
|
||||
{ value: 'auto', title: t('settingsSchemeAutoTitle'), desc: t('settingsSchemeAutoDesc') },
|
||||
{ value: 'web', title: t('settingsSchemeWebTitle'), desc: t('settingsSchemeWebDesc') },
|
||||
...getShellPresets().map(preset => ({
|
||||
value: `preset:${preset.id}`,
|
||||
title: preset.title,
|
||||
desc: preset.detect?.(detectedEnv) === true
|
||||
? `${preset.desc}(${t('settingsSchemeDetectedSuffix')})`
|
||||
: preset.desc,
|
||||
})),
|
||||
{ value: 'custom', title: t('settingsSchemeCustomTitle'), desc: t('settingsSchemeCustomDesc') },
|
||||
]}
|
||||
onSelect={onSchemeSelect}
|
||||
/>
|
||||
{prefs.titleBarScheme === 'custom' && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.rowGear}
|
||||
@@ -816,11 +948,6 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
<IconSettingsOutline16 size={14} />
|
||||
</button>
|
||||
)}
|
||||
<Switch
|
||||
label={t('settingsTitleBarTitle')}
|
||||
checked={prefs.titleBarCompat}
|
||||
onChange={(next) => { applyPref({ titleBarCompat: next }) }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -948,10 +1075,11 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* The position-compat strip popup (opened by the gear on the 常规
|
||||
row): one number row for the reserved strip height in px. Same
|
||||
Modal chrome and row machinery as the feature popups — mounted
|
||||
only while open (the Modal SSR rule above). */}
|
||||
{/* The custom-scheme popup (opened by the gear next to the scheme
|
||||
dropdown when 自定义方案 is active): the shift distance in px and
|
||||
the free-form custom CSS. The OTHER schemes (自动检测 / DSH官方Web /
|
||||
壳预设) need no further settings — the scheme itself is chosen on
|
||||
the 常规 row. Mounted only while open (the Modal SSR rule above). */}
|
||||
{stripSettingsOpen && (
|
||||
<Modal
|
||||
open
|
||||
@@ -966,20 +1094,29 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<FeatureSettingsRows
|
||||
toggles={[{
|
||||
key: 'titleBarStripPx',
|
||||
type: 'number',
|
||||
title: () => t('settingsTitleBarStripTitle'),
|
||||
desc: () => t('settingsTitleBarStripDesc'),
|
||||
min: TITLE_BAR_STRIP_MIN,
|
||||
max: TITLE_BAR_STRIP_MAX,
|
||||
unit: 'px',
|
||||
}]}
|
||||
prefs={prefs}
|
||||
onToggle={onToggleSetting}
|
||||
onCommit={onCommitSetting}
|
||||
/>
|
||||
<div className={css.popupRows}>
|
||||
<FeatureSettingsRows
|
||||
toggles={[{
|
||||
key: 'titleBarStripPx',
|
||||
type: 'number',
|
||||
title: () => t('settingsTitleBarStripTitle'),
|
||||
desc: () => t('settingsTitleBarStripDesc'),
|
||||
min: TITLE_BAR_STRIP_MIN,
|
||||
max: TITLE_BAR_STRIP_MAX,
|
||||
unit: 'px',
|
||||
}]}
|
||||
prefs={prefs}
|
||||
onToggle={onToggleSetting}
|
||||
onCommit={onCommitSetting}
|
||||
/>
|
||||
<CssDraft
|
||||
key={prefs.customCss}
|
||||
value={prefs.customCss}
|
||||
label={t('settingsCustomCssTitle')}
|
||||
placeholder={t('settingsCustomCssPlaceholder')}
|
||||
onCommit={commitCustomCss}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
|
||||
+62
-21
@@ -44,6 +44,9 @@ 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 { getWcoSnapshot, subscribeWco } from './wco.ts'
|
||||
import { getShellPreset } from './shell-presets.ts'
|
||||
import { computeTitleBarStrip } from './titlebar-strip.ts'
|
||||
import type { NewTabOption } from './TabBar.tsx'
|
||||
import type { TabDragPayload } from './TabBar.tsx'
|
||||
import { relativeTo } from './paths.ts'
|
||||
@@ -86,6 +89,20 @@ const osFileDragShield = {
|
||||
onDrop: swallowOsFileDrag,
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one user-space stylesheet (preset or custom CSS) as a tagged
|
||||
* `<style>` element. The tag attribute carries the source identity so the
|
||||
* running configuration is inspectable in DevTools; the returned tag is
|
||||
* removed by the caller's effect cleanup.
|
||||
*/
|
||||
function injectUserCss(attr: string, id: string, cssText: string): HTMLStyleElement {
|
||||
const tag = document.createElement('style')
|
||||
tag.setAttribute(attr, id)
|
||||
tag.textContent = cssText
|
||||
document.head.appendChild(tag)
|
||||
return tag
|
||||
}
|
||||
|
||||
/** Render the content of one tab (dispatched by type). */
|
||||
function TabContent(props: {
|
||||
tab: SidebarTab
|
||||
@@ -229,25 +246,31 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
return () => { document.body.removeAttribute('data-dsh-sidebar-collapsed') }
|
||||
}, [collapsed])
|
||||
|
||||
// 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 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.
|
||||
// Title-bar / shell compatibility (the "位置兼容模式" scheme):
|
||||
// auto — CONSERVATIVE: only the standard Window Controls Overlay
|
||||
// geometry contributes (the real caption-overlay height,
|
||||
// reactive to maximize/restore). No URL stamp, no preset, no
|
||||
// guess — plain browsers see zero modification.
|
||||
// preset — an opt-in built-in shell preset (shell-presets.ts) adds its
|
||||
// per-shell strip as the no-WCO fallback.
|
||||
// custom — the user's own CSS (injected below) + the legacy manual
|
||||
// strip px.
|
||||
// The resolved strip drives the SAME body attribute + CSS variable as the
|
||||
// legacy boolean did, so the CSS contract is unchanged (layout.css /
|
||||
// sidebar.module.css); only the value source changed. The cleanup removes
|
||||
// both on unmount/boundary swap so a crashed sidebar never leaves them
|
||||
// behind.
|
||||
const desktopEnv = parseDesktopEnv()
|
||||
const autoTitleBarCompat = desktopEnv.win32OverlayTop > 0
|
||||
const titleBarCompat = snapshot.prefs.titleBarCompat || autoTitleBarCompat
|
||||
const titleBarStrip = snapshot.prefs.titleBarCompat
|
||||
? snapshot.prefs.titleBarStripPx
|
||||
: desktopEnv.win32OverlayTop
|
||||
const wco = useSyncExternalStore(
|
||||
useMemo(() => subscribeWco, []),
|
||||
getWcoSnapshot,
|
||||
)
|
||||
const scheme = snapshot.prefs.titleBarScheme
|
||||
const preset = scheme === 'preset' ? getShellPreset(snapshot.prefs.titleBarPresetId) : undefined
|
||||
const titleBarStrip = computeTitleBarStrip(
|
||||
desktopEnv, wco, scheme, preset, snapshot.prefs.titleBarStripPx,
|
||||
)
|
||||
const titleBarCompat = titleBarStrip > 0
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
if (titleBarCompat) {
|
||||
@@ -263,6 +286,22 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
}
|
||||
}, [titleBarCompat, titleBarStrip])
|
||||
|
||||
// User-space CSS injection (the escape hatch): preset CSS (scheme
|
||||
// `preset`) and free-form custom CSS (scheme `custom`) are appended AFTER
|
||||
// the plugin's own styles — later in the cascade wins ties, and
|
||||
// `!important` can override the JS-written inline strip variable. Each
|
||||
// source gets its own tagged <style> so the running configuration stays
|
||||
// inspectable; tags are removed on change/unmount so a stale stylesheet
|
||||
// never outlives its fiber (HMR-safe).
|
||||
const presetCss = scheme === 'preset' ? preset?.css ?? '' : ''
|
||||
const customCss = scheme === 'custom' ? snapshot.prefs.customCss : ''
|
||||
useEffect(() => {
|
||||
const tags: HTMLStyleElement[] = []
|
||||
if (presetCss !== '') tags.push(injectUserCss('data-dsh-preset-css', preset?.id ?? '', presetCss))
|
||||
if (customCss !== '') tags.push(injectUserCss('data-dsh-custom-css', 'custom', customCss))
|
||||
return () => { for (const tag of tags) tag.remove() }
|
||||
}, [presetCss, customCss, preset?.id])
|
||||
|
||||
/**
|
||||
* Bottom-panel merge on narrow viewports: whenever a session is current
|
||||
* while narrow (mount, session switch, or a desktop→narrow transition),
|
||||
@@ -891,7 +930,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
if (state === undefined || sessionId === undefined) {
|
||||
return (
|
||||
<div data-dsh-panel-host {...osFileDragShield}>
|
||||
<div className={css.toggleCluster}>
|
||||
<div className={css.toggleCluster} data-dsh-toggle-cluster>
|
||||
{!narrow && (
|
||||
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
|
||||
@@ -985,7 +1024,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
right end it really squeezes (the strip reserves its width via CSS),
|
||||
so the tabs genuinely yield space to it.
|
||||
*/}
|
||||
<div className={css.toggleCluster}>
|
||||
<div className={css.toggleCluster} data-dsh-toggle-cluster>
|
||||
{/*
|
||||
Narrow viewports merge the two workbenches into the one drawer —
|
||||
there is no bottom panel, so its toggle button is not offered.
|
||||
@@ -1025,6 +1064,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={clsx(css.panel, !state.panelOpen && css.panelHidden)}
|
||||
data-dsh-panel
|
||||
style={{
|
||||
width: narrow ? '100vw' : Math.min(state.width, window.innerWidth),
|
||||
// Narrow drawer: keep the bottom-anchored sheet above the on-screen
|
||||
@@ -1155,7 +1195,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
<div
|
||||
ref={bottomRef}
|
||||
className={clsx(css.bottomPanel, !state.bottomOpen && css.bottomPanelHidden)}
|
||||
data-dsh-bottom-panel=""
|
||||
data-dsh-panel
|
||||
data-dsh-bottom-panel
|
||||
style={{
|
||||
height: Math.min(state.bottomHeight, window.innerHeight),
|
||||
left: centerRect.left,
|
||||
|
||||
+38
-24
@@ -1,21 +1,18 @@
|
||||
/**
|
||||
* Desktop-shell detection for the sidebar. The official DSH Desktop shell
|
||||
* (Electron) stamps every render URL with `dsh-desktop-mode` and
|
||||
* `dsh-desktop-platform` and exposes `window.__DSH_DESKTOP_FILE_PATH__`
|
||||
* through its preload; community Tauri shells keep the native system frame
|
||||
* (no stamps — plain-browser semantics). Parsed once per page and memoized
|
||||
* (the URL never changes mid-session); `resetDesktopEnvForTests` clears the
|
||||
* memo for unit tests.
|
||||
* Desktop-shell detection for the sidebar. Shells may stamp the render URL
|
||||
* with `dsh-desktop-mode` / `dsh-desktop-platform` (the official Electron
|
||||
* shell does) and expose `window.__DSH_DESKTOP_FILE_PATH__` through a
|
||||
* preload. Parsed once per page and memoized (the URL never changes
|
||||
* mid-session); `resetDesktopEnvForTests` clears the memo for unit tests.
|
||||
*
|
||||
* Geometry facts the sidebar adapts to (from the Electron runtime):
|
||||
* - win32 `advanced` mode draws the window controls in a 32px overlay strip
|
||||
* at the top-right of the web content (`titleBarOverlay: { height: 32 }`)
|
||||
* — exactly where the toggle cluster sits, so it must drop below the
|
||||
* strip and the right panel's content must clear it.
|
||||
* - darwin `advanced` mode keeps the traffic lights at (16,16) in the
|
||||
* top-left; nothing on the plugin's side touches that corner today, but
|
||||
* the platform is still reported so left-edge controls can avoid it.
|
||||
* - `compatibility` mode keeps the native frame — no adaptation needed.
|
||||
* GEOMETRY POLICY: this module only REPORTS shell facts — it never decides
|
||||
* how to adapt. The strip height comes from standard signals first (the
|
||||
* Window Controls Overlay API, see wco.ts), then the documented contract
|
||||
* parameter `dsh-desktop-titlebar-inset` (a shell may stamp the real pixels
|
||||
* it reserves at the top), then the user's chosen scheme (preset / custom).
|
||||
* The legacy win32-advanced 32px constant is gone from the core: it lives
|
||||
* in the opt-in shell preset (shell-presets.ts) as a fallback for shells
|
||||
* without the WCO API.
|
||||
*/
|
||||
export interface DesktopEnv {
|
||||
/** Running inside a desktop shell (any URL stamp or preload marker). */
|
||||
@@ -24,34 +21,51 @@ export interface DesktopEnv {
|
||||
readonly mode: 'compatibility' | 'advanced' | null
|
||||
/** Shell platform stamp ('darwin' | 'win32' | …), lowercased, or null. */
|
||||
readonly platform: string | null
|
||||
/** Pixels the win32 advanced shell reserves at the top-right for window
|
||||
* controls (0 elsewhere). */
|
||||
readonly win32OverlayTop: number
|
||||
/**
|
||||
* Contract parameter `dsh-desktop-titlebar-inset`: pixels the shell
|
||||
* reserves at the top of the web content for its own chrome (0–120,
|
||||
* clamped; 0 when absent). Standard WCO geometry takes precedence over
|
||||
* this whenever the API is available.
|
||||
*/
|
||||
readonly titlebarInset: number
|
||||
}
|
||||
|
||||
let cached: DesktopEnv | undefined
|
||||
|
||||
/** Read the shell's desktop stamps (memoized per page). */
|
||||
/** Read the shell's desktop stamps (memoized per page; SSR-safe). */
|
||||
export function parseDesktopEnv(): DesktopEnv {
|
||||
if (cached !== undefined) return cached
|
||||
const hasWindow = typeof window !== 'undefined'
|
||||
const hasPreloadMarker = hasWindow
|
||||
&& typeof (window as { __DSH_DESKTOP_FILE_PATH__?: unknown }).__DSH_DESKTOP_FILE_PATH__ !== 'undefined'
|
||||
// location.search includes the leading '?', which URLSearchParams does NOT
|
||||
// strip (it would become part of the first key) — drop it explicitly.
|
||||
const params = new URLSearchParams(window.location.search.replace(/^\?/, ''))
|
||||
// SSR (no window) resolves the empty environment: nothing desktop.
|
||||
const params = hasWindow
|
||||
? new URLSearchParams(window.location.search.replace(/^\?/, ''))
|
||||
: new URLSearchParams()
|
||||
const modeParam = params.get('dsh-desktop-mode')
|
||||
const mode = modeParam === 'compatibility' || modeParam === 'advanced' ? modeParam : null
|
||||
const platformParam = params.get('dsh-desktop-platform')
|
||||
const platform = platformParam !== null && platformParam !== '' ? platformParam.toLowerCase() : null
|
||||
const desktop = mode !== null
|
||||
|| typeof (window as { __DSH_DESKTOP_FILE_PATH__?: unknown }).__DSH_DESKTOP_FILE_PATH__ !== 'undefined'
|
||||
const desktop = mode !== null || hasPreloadMarker
|
||||
cached = {
|
||||
desktop,
|
||||
mode,
|
||||
platform,
|
||||
win32OverlayTop: desktop && mode === 'advanced' && platform === 'win32' ? 32 : 0,
|
||||
titlebarInset: parseTitlebarInset(params.get('dsh-desktop-titlebar-inset')),
|
||||
}
|
||||
return cached
|
||||
}
|
||||
|
||||
/** Clamp the contract inset parameter into 0–120 (invalid/absent → 0). */
|
||||
function parseTitlebarInset(raw: string | null): number {
|
||||
if (raw === null) return 0
|
||||
const parsed = Number(raw)
|
||||
if (!Number.isFinite(parsed)) return 0
|
||||
return Math.min(120, Math.max(0, Math.round(parsed)))
|
||||
}
|
||||
|
||||
/** Test hook: drop the memo so the next parse re-reads the URL/globals. */
|
||||
export function resetDesktopEnvForTests(): void {
|
||||
cached = undefined
|
||||
|
||||
+12
-4
@@ -35,8 +35,14 @@
|
||||
width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
/* The AppFrame's center column, anchored by data attributes (see above). */
|
||||
#root [data-dsh-frame] > [data-pane="conversation"] {
|
||||
/* The AppFrame's center column, anchored by data attributes (see above).
|
||||
Composite selector: DSH 0.1.x versions name the center grid item
|
||||
`[data-pane="conversation"]`, while rc.8-era shells put a
|
||||
`[data-slot="conversation"]` child inside it — both selectors resolve to
|
||||
the SAME element on live pages (verified), and keeping both future-proofs
|
||||
the rule against a host rename without touching shell-specific markup. */
|
||||
#root [data-dsh-frame] > [data-pane="conversation"],
|
||||
#root :has(> [data-slot="conversation"]) {
|
||||
margin-bottom: var(--dsh-sidebar-height, 0px);
|
||||
transition: margin-bottom var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
@@ -55,7 +61,8 @@ body[data-dsh-sidebar-collapsed] [data-slot="conversation.session.header"] > hea
|
||||
}
|
||||
|
||||
body[data-dsh-sidebar-dragging] #root,
|
||||
body[data-dsh-sidebar-dragging] #root [data-dsh-frame] > [data-pane="conversation"] {
|
||||
body[data-dsh-sidebar-dragging] #root [data-dsh-frame] > [data-pane="conversation"],
|
||||
body[data-dsh-sidebar-dragging] #root :has(> [data-slot="conversation"]) {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@@ -80,7 +87,8 @@ body[data-dsh-sidebar-dragging] #root [data-dsh-frame] > [data-pane="conversatio
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#root,
|
||||
#root [data-dsh-frame] > [data-pane="conversation"] {
|
||||
#root [data-dsh-frame] > [data-pane="conversation"],
|
||||
#root :has(> [data-slot="conversation"]) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
+24
-4
@@ -154,9 +154,19 @@ export const zh = {
|
||||
settingsOpenPathTitle: '聊天区文件在侧边栏打开',
|
||||
settingsOpenPathDesc: '在聊天里点击文件链接(工具行、产物列表、文件提及)时,在侧边栏编辑器中打开,不再调用系统默认应用',
|
||||
settingsTitleBarTitle: '位置兼容模式',
|
||||
settingsTitleBarDesc: '为 Windows 右上角的原生标题栏预留空间:侧边栏按钮与侧边栏内容整体下移,避免被标题栏遮挡',
|
||||
settingsTitleBarDesc: '选择顶栏兼容方案:自动检测(默认,保守)/ DSH官方Web / 已知桌面壳 / 自定义方案(下移距离 + 自定义 CSS)',
|
||||
settingsTitleBarStripTitle: '下移距离',
|
||||
settingsTitleBarStripDesc: '标题栏条带高度:侧边栏按钮与内容下移的像素数(0–120,默认 40)',
|
||||
settingsTitleBarStripDesc: '标题栏条带高度:侧边栏按钮与内容下移的像素数(0–120,默认 40;自定义方案下生效)',
|
||||
settingsSchemeAutoTitle: '自动检测',
|
||||
settingsSchemeAutoDesc: '保守方案:仅在 Window Controls Overlay 标准 API 可用时按真实标题栏高度让位;网页环境下不做任何修改',
|
||||
settingsSchemeWebTitle: 'DSH官方Web',
|
||||
settingsSchemeWebDesc: '显式声明运行在官方网页版:不做任何适配(连标准 WCO 几何也不适用)',
|
||||
settingsSchemeCustomTitle: '自定义方案',
|
||||
settingsSchemeCustomDesc: '完全由你控制:注入自定义 CSS(可覆盖内置样式),并指定标题栏下移距离',
|
||||
settingsSchemeDetectedSuffix: '已检测',
|
||||
settingsCustomCssTitle: '自定义 CSS',
|
||||
settingsCustomCssDesc: '追加到页面末尾的样式(同优先级下后写胜出;覆盖 JS 内联变量需用 !important)',
|
||||
settingsCustomCssPlaceholder: '/* 例:为自绘标题栏的壳预留 36px */\nhtml[data-dsh-title-bar-height="36"] {\n --dsh-title-bar-strip: 36px !important;\n}',
|
||||
settingsSaveFailed: '保存失败',
|
||||
settingsConflict: '设置已被其他窗口修改,请重试',
|
||||
binaryNoPreview: '此文件类型不支持预览',
|
||||
@@ -422,9 +432,19 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
settingsOpenPathTitle: 'Open chat files in the sidebar',
|
||||
settingsOpenPathDesc: 'Open file links in the chat (tool rows, produced files, mentions) in the sidebar editor instead of the system default app',
|
||||
settingsTitleBarTitle: 'Position compatibility mode',
|
||||
settingsTitleBarDesc: 'Reserve space for the native Windows title bar at the top-right so the sidebar buttons and content sit below it instead of underneath',
|
||||
settingsTitleBarDesc: 'Pick the title-bar compatibility scheme: auto-detect (default, conservative) / DSH official web / known desktop shells / custom (shift distance + custom CSS)',
|
||||
settingsTitleBarStripTitle: 'Shift distance',
|
||||
settingsTitleBarStripDesc: 'Title-bar strip height: how far the sidebar buttons and content move down in px (0–120, default 40)',
|
||||
settingsTitleBarStripDesc: 'Title-bar strip height: how far the sidebar buttons and content move down in px (0–120, default 40; applies under the custom scheme)',
|
||||
settingsSchemeAutoTitle: 'Auto-detect',
|
||||
settingsSchemeAutoDesc: 'Conservative: only the standard Window Controls Overlay API contributes (real caption-overlay height); plain web environments get no modification',
|
||||
settingsSchemeWebTitle: 'DSH official web',
|
||||
settingsSchemeWebDesc: 'Explicitly declare the official web UI: no adaptation at all (not even standard WCO geometry)',
|
||||
settingsSchemeCustomTitle: 'Custom',
|
||||
settingsSchemeCustomDesc: 'Full control: inject custom CSS (can override built-in styles) and set the title-bar shift distance',
|
||||
settingsSchemeDetectedSuffix: 'detected',
|
||||
settingsCustomCssTitle: 'Custom CSS',
|
||||
settingsCustomCssDesc: 'Styles appended at the end of the page (later in the cascade wins ties; use !important to override JS-written inline variables)',
|
||||
settingsCustomCssPlaceholder: '/* e.g. reserve 36px for a shell with a custom-drawn title bar */\nhtml[data-dsh-title-bar-height="36"] {\n --dsh-title-bar-strip: 36px !important;\n}',
|
||||
settingsSaveFailed: 'Failed to save',
|
||||
settingsConflict: 'The setting changed in another window — please retry',
|
||||
binaryNoPreview: 'This file type cannot be previewed',
|
||||
|
||||
+43
-2
@@ -14,11 +14,21 @@ import {
|
||||
clampTitleBarStrip,
|
||||
clampWidthPercent,
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
TITLE_BAR_SCHEMES,
|
||||
TITLE_BAR_STRIP_DEFAULT,
|
||||
type SidebarPrefs,
|
||||
type TitleBarScheme,
|
||||
} from '../prefs-shared.ts'
|
||||
|
||||
export { SIDEBAR_PREFS_DEFAULTS, clampTerminalFontSize, clampTitleBarStrip, clampWidthPercent }
|
||||
export type { SidebarPrefs }
|
||||
export {
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
TITLE_BAR_SCHEMES,
|
||||
TITLE_BAR_STRIP_DEFAULT,
|
||||
clampTerminalFontSize,
|
||||
clampTitleBarStrip,
|
||||
clampWidthPercent,
|
||||
}
|
||||
export type { SidebarPrefs, TitleBarScheme }
|
||||
|
||||
/** The settings wire face the preferences need (a subset of the plugin api). */
|
||||
export type SidebarSettingsClient = Pick<typeof api, 'settingsGet' | 'settingsUpdate'>
|
||||
@@ -69,6 +79,22 @@ export function parsePrefs(value: unknown): SidebarPrefs {
|
||||
editorExplorer: typeof record.editorExplorer === 'boolean'
|
||||
? record.editorExplorer
|
||||
: SIDEBAR_PREFS_DEFAULTS.editorExplorer,
|
||||
// The title-bar scheme (auto | web | preset | custom). The schema
|
||||
// declares the field WITHOUT a default, so documents written by older
|
||||
// plugin versions resolve without it — migrate from the legacy fields:
|
||||
// a document that ALREADY HAS VALUES (the manual compat flag on, or a
|
||||
// non-default strip px — both only reachable through the old gear
|
||||
// popup) maps to the `custom` scheme so the user's numbers keep
|
||||
// working; a pristine document keeps the conservative `auto` scheme.
|
||||
titleBarScheme: isTitleBarScheme(record.titleBarScheme)
|
||||
? record.titleBarScheme
|
||||
: (record.titleBarCompat === true || hasLegacyStripValue(record.titleBarStripPx) ? 'custom' : 'auto'),
|
||||
titleBarPresetId: typeof record.titleBarPresetId === 'string'
|
||||
? record.titleBarPresetId
|
||||
: SIDEBAR_PREFS_DEFAULTS.titleBarPresetId,
|
||||
customCss: typeof record.customCss === 'string'
|
||||
? record.customCss
|
||||
: SIDEBAR_PREFS_DEFAULTS.customCss,
|
||||
titleBarCompat: typeof record.titleBarCompat === 'boolean'
|
||||
? record.titleBarCompat
|
||||
: SIDEBAR_PREFS_DEFAULTS.titleBarCompat,
|
||||
@@ -130,6 +156,21 @@ function booleanMapOf(value: unknown): Record<string, boolean> {
|
||||
return out
|
||||
}
|
||||
|
||||
/** Type guard for the title-bar scheme union (anything else falls back). */
|
||||
function isTitleBarScheme(value: unknown): value is TitleBarScheme {
|
||||
return typeof value === 'string' && (TITLE_BAR_SCHEMES as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the legacy document carries an explicit strip value (only
|
||||
* reachable through the old gear popup): a stored number different from the
|
||||
* default counts as "the user already configured something" and migrates to
|
||||
* the `custom` scheme.
|
||||
*/
|
||||
function hasLegacyStripValue(value: unknown): boolean {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value !== TITLE_BAR_STRIP_DEFAULT
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the resolved side card preferences through the plugin's settings route.
|
||||
* @param settings - the settings wire face (the plugin api by default).
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Built-in desktop-shell presets for the "位置兼容模式" secondary setting
|
||||
* (scheme `preset`). DATA-DRIVEN by design: each shell's adaptation lives
|
||||
* in one entry (strip values / optional CSS), the core applies it through
|
||||
* the SAME generic strip variable and CSS-injection mechanism — adding a
|
||||
* shell is adding data, never a code path.
|
||||
*
|
||||
* Inclusion rule (maintained in AGENTS.md §8): only shells that (a) appear
|
||||
* in this repo's issues/PRs (a user actually hit a problem) and (b) have
|
||||
* 100+ GitHub stars (a real user base). The mechanism is opt-in: auto
|
||||
* detection never applies a preset — the settings badge only SUGGESTS it.
|
||||
*/
|
||||
import type { DesktopEnv } from './desktop-env.ts'
|
||||
|
||||
export interface ShellPreset {
|
||||
/** Stable preset id (persisted in `titleBarPresetId`). */
|
||||
readonly id: string
|
||||
/** User-facing name of the shell. */
|
||||
readonly title: string
|
||||
/** One-line description shown in the settings popup. */
|
||||
readonly desc: string
|
||||
/**
|
||||
* The top strip (px) this shell reserves over web content, per
|
||||
* environment — the fallback used when neither the standard WCO API nor
|
||||
* the `dsh-desktop-titlebar-inset` contract parameter is available.
|
||||
* Return undefined when the shell needs no strip in that environment.
|
||||
* MUST be pure (called during render).
|
||||
*/
|
||||
readonly stripFor?: (env: DesktopEnv) => number | undefined
|
||||
/**
|
||||
* Extra CSS applied while the preset is enabled (injected last, after the
|
||||
* plugin's own styles). Targets the plugin's stable data attributes and
|
||||
* shell-declared body/URL markers only — never other shells' class names.
|
||||
* Empty for presets fully covered by the strip variable.
|
||||
*/
|
||||
readonly css?: string
|
||||
/**
|
||||
* Whether this shell's marker is currently visible (URL stamps / preload
|
||||
* markers — see desktop-env.ts). Used ONLY for the settings "已检测"
|
||||
* badge; never auto-applies anything. MUST be pure.
|
||||
*/
|
||||
readonly detect?: (env: DesktopEnv) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* DeepSeek Harness Desktop (anywhere-labs, Electron, 16k+ stars — the most
|
||||
* reported shell in this repo's issues/PRs). Advanced mode: macOS reserves
|
||||
* a 20px caption row (traffic lights top-left), win32 draws the native
|
||||
* window controls in a ~32px overlay (WCO reports the real height when
|
||||
* available, which the auto scheme already consumes; the 32 is the
|
||||
* no-WCO fallback). Compatibility mode keeps the native frame — nothing.
|
||||
*/
|
||||
const DSH_DESKTOP: ShellPreset = {
|
||||
id: 'dsh-desktop',
|
||||
title: 'DeepSeek Harness Desktop',
|
||||
desc: 'Electron 高级模式(无边框):macOS 顶栏 20px、Windows 无 WCO 时 32px 标题栏让位',
|
||||
stripFor: (env) => {
|
||||
if (env.mode !== 'advanced') return undefined
|
||||
if (env.platform === 'darwin') return 20
|
||||
if (env.platform === 'win32') return 32
|
||||
return undefined
|
||||
},
|
||||
detect: (env) => env.mode === 'advanced',
|
||||
}
|
||||
|
||||
const PRESETS: readonly ShellPreset[] = [
|
||||
DSH_DESKTOP,
|
||||
]
|
||||
|
||||
/** All built-in shell presets (registration order = settings list order). */
|
||||
export function getShellPresets(): readonly ShellPreset[] {
|
||||
return PRESETS
|
||||
}
|
||||
|
||||
/** One preset by id, or undefined for an unknown/empty id. */
|
||||
export function getShellPreset(id: string): ShellPreset | undefined {
|
||||
return PRESETS.find(preset => preset.id === id)
|
||||
}
|
||||
|
||||
/** The strip the active preset contributes for the given environment. */
|
||||
export function presetStripFor(preset: ShellPreset | undefined, env: DesktopEnv): number | undefined {
|
||||
return preset?.stripFor?.(env)
|
||||
}
|
||||
@@ -282,6 +282,21 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
/* ── Desktop drag-region opt-out ──────────────────────────────────────── */
|
||||
|
||||
/* Frameless desktop shells make the top strip of the web content draggable
|
||||
(Electron `-webkit-app-region: drag`); pointer events over that strip are
|
||||
swallowed as window drags unless the interactive element opts out
|
||||
(issues #103/#111). The plugin's viewport-corner chrome must declare
|
||||
`no-drag` itself — some shells (Tauri, older desktop builds, third-party
|
||||
wrappers) have no blanket button rule. The property is a no-op in plain
|
||||
browsers and in shells without drag regions — safe everywhere. */
|
||||
.toggleCluster,
|
||||
.toggleButton,
|
||||
.tabBar {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* ── Position compatibility mode (Windows native title bar) ────────────── */
|
||||
|
||||
/* Windows frameless / hidden-title-bar windows draw the native caption
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* The title-bar strip resolution chain — the ONE place that decides how
|
||||
* many pixels the sidebar yields at the top. Standard signals first, then
|
||||
* the user's chosen scheme; never a per-shell branch:
|
||||
*
|
||||
* 0. `web` scheme — EXPLICIT "DSH official web": never adapt, not even
|
||||
* standard WCO geometry (the user declares the plain web UI).
|
||||
* 1. Window Controls Overlay real geometry (standard API, authoritative
|
||||
* when present — even 0, e.g. the overlay is hidden while maximized).
|
||||
* 2. The `dsh-desktop-titlebar-inset` URL contract parameter (a shell
|
||||
* declares the exact pixels it reserves).
|
||||
* 3. The active shell preset's strip (scheme `preset` — opt-in data).
|
||||
* 4. The legacy manual `titleBarStripPx` (scheme `custom`).
|
||||
* 5. 0 — plain-browser semantics, nothing modified.
|
||||
*
|
||||
* The result drives `--dsh-title-bar-strip` + `body[data-dsh-title-bar-compat]`
|
||||
* exactly like the legacy boolean did; only the VALUE source changed.
|
||||
*/
|
||||
import type { DesktopEnv } from './desktop-env.ts'
|
||||
import type { TitleBarScheme } from '../prefs-shared.ts'
|
||||
import type { WcoSnapshot } from './wco.ts'
|
||||
import { presetStripFor, type ShellPreset } from './shell-presets.ts'
|
||||
|
||||
export function computeTitleBarStrip(
|
||||
env: DesktopEnv,
|
||||
wco: WcoSnapshot,
|
||||
scheme: TitleBarScheme,
|
||||
preset: ShellPreset | undefined,
|
||||
customStripPx: number,
|
||||
): number {
|
||||
if (scheme === 'web') return 0
|
||||
if (wco.present) return wco.height
|
||||
if (env.titlebarInset > 0) return env.titlebarInset
|
||||
if (scheme === 'preset') return presetStripFor(preset, env) ?? 0
|
||||
if (scheme === 'custom') return customStripPx
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Reactive Window Controls Overlay geometry — the STANDARD web mechanism
|
||||
* for frameless Chromium shells that draw the native caption buttons
|
||||
* (minimize / maximize / close) OVER the web content (Electron
|
||||
* `titleBarOverlay` on Windows; issue #257). Feature-detected: absent in
|
||||
* plain browsers, macOS, Tauri etc., where the snapshot stays `NONE` and
|
||||
* the sidebar adapts nothing — this is the conservative "auto" signal.
|
||||
*
|
||||
* The API reports the real titlebar rect (CSS px), which differs between
|
||||
* shells and Electron versions (32 / 36 …), and fires `geometrychange`
|
||||
* when the window maximizes / restores / moves — so the strip must be
|
||||
* reactive, not memoized like the URL stamps.
|
||||
*
|
||||
* Module-level store so the Sidebar shell can subscribe via
|
||||
* `useSyncExternalStore`; `setWcoSourceForTests` swaps the source for unit
|
||||
* tests (no real API exists in jsdom/headless).
|
||||
*/
|
||||
export interface WcoSource {
|
||||
readonly visible: boolean
|
||||
getTitlebarAreaRect(): { readonly x: number; readonly y: number; readonly width: number; readonly height: number }
|
||||
addEventListener(type: 'geometrychange', listener: () => void): void
|
||||
removeEventListener(type: 'geometrychange', listener: () => void): void
|
||||
}
|
||||
|
||||
/** The current overlay geometry (present=false = API unavailable). */
|
||||
export interface WcoSnapshot {
|
||||
readonly present: boolean
|
||||
readonly height: number
|
||||
}
|
||||
|
||||
/** Snapshot when the API is unavailable (plain browser / non-overlay shell). */
|
||||
export const WCO_NONE: WcoSnapshot = Object.freeze({ present: false, height: 0 })
|
||||
|
||||
type Listener = () => void
|
||||
|
||||
let source: WcoSource | undefined
|
||||
let snapshot: WcoSnapshot = WCO_NONE
|
||||
let attached = false
|
||||
let sourceListener: (() => void) | undefined
|
||||
const listeners = new Set<Listener>()
|
||||
|
||||
function read(): WcoSnapshot {
|
||||
if (source === undefined) return WCO_NONE
|
||||
try {
|
||||
// A present-but-NOT-visible API is a PHANTOM: headless Chromium and
|
||||
// macOS builds expose the interface but draw no overlay (empty rect,
|
||||
// visible=false). Only a genuinely visible overlay is authoritative —
|
||||
// otherwise the resolution chain would trust a zero rect and silently
|
||||
// skip the shell's real reserved space.
|
||||
if (source.visible !== true) return { present: false, height: 0 }
|
||||
const rect = source.getTitlebarAreaRect()
|
||||
const height = Math.round(rect.height)
|
||||
return Number.isFinite(height) && height > 0
|
||||
? { present: true, height }
|
||||
: { present: true, height: 0 }
|
||||
} catch {
|
||||
// A hostile/racy API must never break the layout — treat as absent.
|
||||
return { present: false, height: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
function onGeometryChange(): void {
|
||||
snapshot = read()
|
||||
emit()
|
||||
}
|
||||
|
||||
function emit(): void {
|
||||
for (const listener of listeners) listener()
|
||||
}
|
||||
|
||||
/** Attach the native geometrychange listener (once). */
|
||||
function attach(): void {
|
||||
if (attached) return
|
||||
attached = true
|
||||
// The test hook may have installed a source already; otherwise attach to
|
||||
// the real navigator API (absent in plain browsers → stay NONE).
|
||||
const candidate = source ?? (navigator as unknown as { windowControlsOverlay?: WcoSource }).windowControlsOverlay
|
||||
if (candidate === undefined) return
|
||||
source = candidate
|
||||
sourceListener = onGeometryChange
|
||||
snapshot = read()
|
||||
source.addEventListener('geometrychange', sourceListener)
|
||||
}
|
||||
|
||||
/** Detach the native listener (last subscriber left or source swapped). */
|
||||
function detach(): void {
|
||||
if (source !== undefined && sourceListener !== undefined) {
|
||||
source.removeEventListener('geometrychange', sourceListener)
|
||||
}
|
||||
sourceListener = undefined
|
||||
attached = false
|
||||
}
|
||||
|
||||
/** Read the current snapshot (returns the frozen NONE when unavailable). */
|
||||
export function getWcoSnapshot(): WcoSnapshot {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to overlay geometry changes. Attaches to the real
|
||||
* `navigator.windowControlsOverlay` on first subscribe; the disposer
|
||||
* detaches the native listener when the last subscriber leaves.
|
||||
*/
|
||||
export function subscribeWco(onChange: Listener): () => void {
|
||||
listeners.add(onChange)
|
||||
attach()
|
||||
return () => {
|
||||
listeners.delete(onChange)
|
||||
if (listeners.size === 0) detach()
|
||||
}
|
||||
}
|
||||
|
||||
/** Test hook: swap the geometry source (undefined = API unavailable). */
|
||||
export function setWcoSourceForTests(next: WcoSource | undefined): void {
|
||||
detach()
|
||||
source = next
|
||||
snapshot = next === undefined ? WCO_NONE : read()
|
||||
if (next !== undefined && listeners.size > 0) attach()
|
||||
emit()
|
||||
}
|
||||
@@ -129,6 +129,9 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
|
||||
editorExplorer: z.boolean().default(false),
|
||||
terminalShell: z.string().default(''),
|
||||
terminalShellArgs: z.string().default(''),
|
||||
titleBarScheme: z.union([z.const('auto'), z.const('web'), z.const('preset'), z.const('custom')]),
|
||||
titleBarPresetId: z.string(),
|
||||
customCss: z.string(),
|
||||
titleBarCompat: z.boolean().default(false),
|
||||
titleBarStripPx: z.number().step(1).min(TITLE_BAR_STRIP_MIN).max(TITLE_BAR_STRIP_MAX).default(TITLE_BAR_STRIP_DEFAULT),
|
||||
htmlViewerNoSandbox: z.boolean().default(false),
|
||||
|
||||
+41
-8
@@ -85,17 +85,43 @@ export interface SidebarPrefs {
|
||||
*/
|
||||
terminalShellArgs: string
|
||||
/**
|
||||
* Position compatibility mode: reserves space at the top for the native
|
||||
* Windows title bar (drawn at the window's top-right corner over the web
|
||||
* content in frameless/hidden-title-bar windows). When on, the toggle
|
||||
* cluster drops below the strip and the right panel's content starts
|
||||
* below it. Off by default — the sidebar layout is untouched.
|
||||
* Title-bar / shell compatibility scheme (the "位置兼容模式" setting):
|
||||
* - `auto` (default): CONSERVATIVE — only the standard Window Controls
|
||||
* Overlay API (present in frameless Chromium shells that draw the
|
||||
* native caption buttons over web content) contributes real geometry;
|
||||
* without it nothing is modified, so plain-browser (web) behavior is
|
||||
* untouched.
|
||||
* - `web`: EXPLICIT "DSH official web" — never adapt, not even WCO
|
||||
* geometry (the user declares they run the plain web UI).
|
||||
* - `preset`: apply the built-in shell preset named by
|
||||
* `titleBarPresetId` (data-driven, opt-in — see shell-presets.ts).
|
||||
* - `custom`: apply the free-form `customCss` (and the legacy
|
||||
* `titleBarStripPx` strip).
|
||||
*/
|
||||
titleBarScheme: TitleBarScheme
|
||||
/**
|
||||
* The built-in shell preset id applied while `titleBarScheme` is
|
||||
* `preset` ('' = no preset — nothing extra is applied).
|
||||
*/
|
||||
titleBarPresetId: string
|
||||
/**
|
||||
* Free-form CSS injected into the page (last in the cascade, so it can
|
||||
* override the plugin's styles; use `!important` to override JS-written
|
||||
* inline CSS variables). Applied while `titleBarScheme` is `custom`.
|
||||
*/
|
||||
customCss: string
|
||||
/**
|
||||
* LEGACY (kept for read-migration and downgrade mirroring only): position
|
||||
* compatibility mode flag. The UI writes `titleBarScheme` instead; a
|
||||
* stored `true` without a scheme migrates to the `custom` scheme (with
|
||||
* `titleBarStripPx` preserved).
|
||||
*/
|
||||
titleBarCompat: boolean
|
||||
/**
|
||||
* The reserved top strip height in px when `titleBarCompat` is on
|
||||
* (0–120, default 40). Drives the `--dsh-title-bar-strip` CSS variable:
|
||||
* the toggle cluster drops `strip + 3px` and the right panel's content
|
||||
* LEGACY (kept for read-migration and downgrade mirroring only): the
|
||||
* reserved top strip height in px used by the `custom` scheme (0–120,
|
||||
* default 40). Drives the `--dsh-title-bar-strip` CSS variable: the
|
||||
* toggle cluster drops `strip + 3px` and the right panel's content
|
||||
* starts `strip` px below its top edge.
|
||||
*/
|
||||
titleBarStripPx: number
|
||||
@@ -192,6 +218,10 @@ export const TITLE_BAR_STRIP_MIN = 0
|
||||
export const TITLE_BAR_STRIP_MAX = 120
|
||||
export const TITLE_BAR_STRIP_DEFAULT = 40
|
||||
|
||||
/** The title-bar / shell compatibility schemes (see {@link SidebarPrefs.titleBarScheme}). */
|
||||
export const TITLE_BAR_SCHEMES = ['auto', 'web', 'preset', 'custom'] as const
|
||||
export type TitleBarScheme = typeof TITLE_BAR_SCHEMES[number]
|
||||
|
||||
/** Fallback prefs used whenever the settings document is unreachable or malformed. */
|
||||
export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
|
||||
openByDefault: false,
|
||||
@@ -206,6 +236,9 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
|
||||
editorExplorer: false,
|
||||
terminalShell: '',
|
||||
terminalShellArgs: '',
|
||||
titleBarScheme: 'auto',
|
||||
titleBarPresetId: '',
|
||||
customCss: '',
|
||||
titleBarCompat: false,
|
||||
titleBarStripPx: TITLE_BAR_STRIP_DEFAULT,
|
||||
htmlViewerNoSandbox: false,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* Desktop-shell detection tests: URL stamps (dsh-desktop-mode/platform)
|
||||
* from the official Electron shell, the preload marker
|
||||
* (__DSH_DESKTOP_FILE_PATH__), and the win32 advanced overlay adaptation.
|
||||
* (__DSH_DESKTOP_FILE_PATH__), and the `dsh-desktop-titlebar-inset`
|
||||
* contract parameter. Geometry ADAPTATION is deliberately NOT here — the
|
||||
* strip resolution chain lives in titlebar-strip.ts (standard WCO first,
|
||||
* see wco.spec.ts), keeping this module a pure environment reporter.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import './browser-globals.ts'
|
||||
@@ -18,25 +21,25 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('parseDesktopEnv', () => {
|
||||
it('reports a plain browser page as non-desktop', () => {
|
||||
expect(parseDesktopEnv()).toEqual({ desktop: false, mode: null, platform: null, win32OverlayTop: 0 })
|
||||
it('reports a plain browser page as non-desktop with no inset', () => {
|
||||
expect(parseDesktopEnv()).toEqual({ desktop: false, mode: null, platform: null, titlebarInset: 0 })
|
||||
})
|
||||
|
||||
it('parses win32 advanced stamps with the 32px window-control overlay', () => {
|
||||
it('parses win32 advanced stamps (no overlay guess — geometry comes from WCO/preset)', () => {
|
||||
setSearch('?dsh-desktop-mode=advanced&dsh-desktop-platform=win32')
|
||||
const env = parseDesktopEnv()
|
||||
expect(env.desktop).toBe(true)
|
||||
expect(env.mode).toBe('advanced')
|
||||
expect(env.platform).toBe('win32')
|
||||
expect(env.win32OverlayTop).toBe(32)
|
||||
expect(env.titlebarInset).toBe(0)
|
||||
})
|
||||
|
||||
it('parses darwin advanced stamps without an overlay (traffic lights are top-left)', () => {
|
||||
it('parses darwin advanced stamps', () => {
|
||||
setSearch('?dsh-desktop-mode=advanced&dsh-desktop-platform=darwin')
|
||||
const env = parseDesktopEnv()
|
||||
expect(env.desktop).toBe(true)
|
||||
expect(env.platform).toBe('darwin')
|
||||
expect(env.win32OverlayTop).toBe(0)
|
||||
expect(env.titlebarInset).toBe(0)
|
||||
})
|
||||
|
||||
it('parses compatibility mode as desktop with the native frame (no adaptation)', () => {
|
||||
@@ -44,7 +47,7 @@ describe('parseDesktopEnv', () => {
|
||||
const env = parseDesktopEnv()
|
||||
expect(env.desktop).toBe(true)
|
||||
expect(env.mode).toBe('compatibility')
|
||||
expect(env.win32OverlayTop).toBe(0)
|
||||
expect(env.titlebarInset).toBe(0)
|
||||
})
|
||||
|
||||
it('detects the desktop preload marker even without URL stamps', () => {
|
||||
@@ -52,7 +55,7 @@ describe('parseDesktopEnv', () => {
|
||||
const env = parseDesktopEnv()
|
||||
expect(env.desktop).toBe(true)
|
||||
expect(env.mode).toBeNull()
|
||||
expect(env.win32OverlayTop).toBe(0)
|
||||
expect(env.titlebarInset).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores unknown mode values (exotic shells keep plain-browser semantics)', () => {
|
||||
@@ -61,6 +64,20 @@ describe('parseDesktopEnv', () => {
|
||||
expect(parseDesktopEnv().desktop).toBe(false)
|
||||
})
|
||||
|
||||
it('reads the documented titlebar-inset contract parameter (clamped 0–120)', () => {
|
||||
setSearch('?dsh-desktop-mode=advanced&dsh-desktop-platform=win32&dsh-desktop-titlebar-inset=36')
|
||||
expect(parseDesktopEnv().titlebarInset).toBe(36)
|
||||
setSearch('?dsh-desktop-mode=advanced&dsh-desktop-titlebar-inset=200')
|
||||
resetDesktopEnvForTests()
|
||||
expect(parseDesktopEnv().titlebarInset).toBe(120)
|
||||
setSearch('?dsh-desktop-mode=advanced&dsh-desktop-titlebar-inset=-5')
|
||||
resetDesktopEnvForTests()
|
||||
expect(parseDesktopEnv().titlebarInset).toBe(0)
|
||||
setSearch('?dsh-desktop-mode=advanced&dsh-desktop-titlebar-inset=abc')
|
||||
resetDesktopEnvForTests()
|
||||
expect(parseDesktopEnv().titlebarInset).toBe(0)
|
||||
})
|
||||
|
||||
it('memoizes across calls until the test hook resets', () => {
|
||||
setSearch('?dsh-desktop-mode=advanced&dsh-desktop-platform=win32')
|
||||
const first = parseDesktopEnv()
|
||||
|
||||
@@ -702,3 +702,34 @@ test('bottom panel never flashes full-width after a width drag release (issue #2
|
||||
).toBeLessThanOrEqual(8)
|
||||
}
|
||||
})
|
||||
|
||||
test('the bottom-push anchor resolves through the composite selectors (at least one; same element when both)', async ({ page }) => {
|
||||
// layout.css pushes the bottom panel via the center column. The selector
|
||||
// is COMPOSITE on purpose: `[data-pane="conversation"]` (0.1.x naming)
|
||||
// and `:has(> [data-slot="conversation"])` (rc.8-era naming) — HOST
|
||||
// VERSIONS MAY RENAME THE ATTRIBUTE (issue #208 comment / PR #226), so
|
||||
// the contract is "at least one resolves", and when both resolve they
|
||||
// must hit the SAME element (otherwise the push would land twice).
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 })
|
||||
const anchors = await page.evaluate(() => {
|
||||
const a = document.querySelector('#root [data-dsh-frame] > [data-pane="conversation"]')
|
||||
const b = document.querySelector('#root :has(> [data-slot="conversation"])')
|
||||
const frame = document.querySelector('#root [data-dsh-frame]')
|
||||
return {
|
||||
a: a !== null,
|
||||
b: b !== null,
|
||||
same: a !== null && b !== null && a === b,
|
||||
frameChildren: frame !== null
|
||||
? [...frame.children].map(el => `${el.tagName}[${[...el.attributes].map(attr => attr.name).filter(name => name.startsWith('data-')).join(',')}]`)
|
||||
: [],
|
||||
}
|
||||
})
|
||||
expect(
|
||||
anchors.a || anchors.b,
|
||||
`at least one bottom-push anchor must resolve on this host (frame children: ${anchors.frameChildren.join(' / ') || 'no [data-dsh-frame]'})`,
|
||||
).toBe(true)
|
||||
if (anchors.a && anchors.b) {
|
||||
expect(anchors.same, 'both selectors must hit the SAME center-column element').toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
+111
-9
@@ -341,20 +341,122 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
|
||||
await page.screenshot({ path: 'test-results/mount-final.png' })
|
||||
})
|
||||
|
||||
test('desktop shell stamps auto-enable the win32 title-bar compatibility mode', async ({ page }) => {
|
||||
test('conservative auto: URL stamps alone never modify the layout; plugin chrome carries the stable data attributes', async ({ page }) => {
|
||||
// The official DSH Desktop shell stamps every render URL with
|
||||
// dsh-desktop-mode / dsh-desktop-platform (and exposes a preload marker).
|
||||
// The win32 advanced shell reserves a 32px overlay for the window
|
||||
// controls where the toggle cluster sits — the sidebar must auto-drop
|
||||
// below it (body[data-dsh-title-bar-compat]) without any manual pref.
|
||||
// dsh-desktop-mode / dsh-desktop-platform. Under the conservative AUTO
|
||||
// scheme, shell stamps are REPORTS, not geometry: without the standard
|
||||
// Window Controls Overlay API the layout must stay untouched (plain-web
|
||||
// semantics) — the strip/body attribute appear only for real standard
|
||||
// geometry (see the WCO scenario below) or an opt-in preset.
|
||||
await page.goto(`${BASE_URL}?dsh-desktop-mode=advanced&dsh-desktop-platform=win32`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 })
|
||||
await expect(
|
||||
page.locator('body[data-dsh-title-bar-compat]'),
|
||||
'win32 advanced shell must auto-enable title-bar compatibility',
|
||||
).toBeAttached({ timeout: 90_000 })
|
||||
// The strip variable must be sized to the shell's 32px overlay.
|
||||
'stamps alone must NOT auto-enable title-bar compatibility under auto',
|
||||
).toHaveCount(0)
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-title-bar-strip')))
|
||||
.toBe('32px')
|
||||
.toBe('')
|
||||
// The stable addressing surface for presets / custom CSS is mounted.
|
||||
await expect(page.locator('[data-dsh-toggle-cluster]')).toBeAttached()
|
||||
await expect(page.locator('[data-dsh-panel]').first()).toBeAttached()
|
||||
// The plugin's interactive chrome opts out of Electron drag regions
|
||||
// (issues #103/#111) — inert in plain browsers, present in the bundle
|
||||
// (the bundler minifies the property's whitespace, so match loosely).
|
||||
const hasNoDragRule = await page.evaluate(() => {
|
||||
for (const tag of document.querySelectorAll('style')) {
|
||||
if (tag.textContent !== null && /-webkit-app-region:\s*no-drag/.test(tag.textContent)) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
expect(hasNoDragRule, 'the bundle must ship the drag-region opt-out rule').toBe(true)
|
||||
})
|
||||
|
||||
test('standard WCO geometry drives the strip reactively (issue #257)', async ({ page }) => {
|
||||
// The Window Controls Overlay API is the STANDARD signal for shells that
|
||||
// draw the native caption buttons over web content (Electron
|
||||
// `titleBarOverlay`). Mock it with the real API shape: the strip must
|
||||
// follow the reported rect and react to geometrychange (maximize/restore).
|
||||
await page.addInitScript(() => {
|
||||
const rect = { x: 0, y: 0, width: 138, height: 36 }
|
||||
const listeners = new Set<() => void>()
|
||||
Object.defineProperty(navigator, 'windowControlsOverlay', {
|
||||
configurable: true,
|
||||
value: {
|
||||
visible: true,
|
||||
getTitlebarAreaRect: () => ({ ...rect }),
|
||||
addEventListener: (type: string, listener: () => void) => { if (type === 'geometrychange') listeners.add(listener) },
|
||||
removeEventListener: (type: string, listener: () => void) => { if (type === 'geometrychange') listeners.delete(listener) },
|
||||
},
|
||||
})
|
||||
;(globalThis as { __wcoMock?: { setHeight: (height: number) => void } }).__wcoMock = {
|
||||
setHeight: (height: number) => {
|
||||
rect.height = height
|
||||
for (const listener of listeners) listener()
|
||||
},
|
||||
}
|
||||
})
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 })
|
||||
// Real reported height (36px, not a hardcoded 32) drives the strip.
|
||||
await expect(page.locator('body[data-dsh-title-bar-compat]')).toBeAttached({ timeout: 90_000 })
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-title-bar-strip')))
|
||||
.toBe('36px')
|
||||
// Maximize → the overlay reports a zero rect → the strip is removed.
|
||||
await page.evaluate(() => (globalThis as { __wcoMock?: { setHeight: (height: number) => void } }).__wcoMock?.setHeight(0))
|
||||
await expect(page.locator('body[data-dsh-title-bar-compat]')).toHaveCount(0)
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-title-bar-strip')))
|
||||
.toBe('')
|
||||
// Restore → the strip comes back.
|
||||
await page.evaluate(() => (globalThis as { __wcoMock?: { setHeight: (height: number) => void } }).__wcoMock?.setHeight(36))
|
||||
await expect(page.locator('body[data-dsh-title-bar-compat]')).toBeAttached()
|
||||
})
|
||||
|
||||
test('opt-in shell preset applies its strip when WCO is absent (data-driven, manual)', async ({ request, page }) => {
|
||||
// The anywhere-labs DSH Desktop preset (shell-presets.ts) is OPT-IN: under
|
||||
// the preset scheme the win32 advanced stamp resolves to its 32px fallback
|
||||
// even without the WCO API; auto never does this.
|
||||
const update = await request.post(`${BASE_URL}/sidebar/api/settings.update`, {
|
||||
data: { patch: { titleBarScheme: 'preset', titleBarPresetId: 'dsh-desktop', titleBarCompat: true } },
|
||||
})
|
||||
expect(update.ok(), `settings.update: ${update.status()}`).toBe(true)
|
||||
try {
|
||||
await page.goto(`${BASE_URL}?dsh-desktop-mode=advanced&dsh-desktop-platform=win32`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 })
|
||||
await expect(page.locator('body[data-dsh-title-bar-compat]')).toBeAttached({ timeout: 90_000 })
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-title-bar-strip')))
|
||||
.toBe('32px')
|
||||
// The v1 anywhere-labs preset is PURE STRIP DATA (no extra CSS — the
|
||||
// injection mechanism is exercised by the custom-scheme test). Assert
|
||||
// the absence explicitly: a future preset that ADDS css would trip
|
||||
// here instead of silently shipping unstyled.
|
||||
await expect(page.locator('style[data-dsh-preset-css]')).toHaveCount(0)
|
||||
} finally {
|
||||
// Restore the shared server state for the lanes after this one.
|
||||
await request.post(`${BASE_URL}/sidebar/api/settings.update`, {
|
||||
data: { patch: { titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false } },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('custom scheme injects the user stylesheet live', async ({ request, page }) => {
|
||||
const update = await request.post(`${BASE_URL}/sidebar/api/settings.update`, {
|
||||
data: { patch: { titleBarScheme: 'custom', customCss: 'html { --dsh-e2e-marker: 1; }', titleBarCompat: true } },
|
||||
})
|
||||
expect(update.ok(), `settings.update: ${update.status()}`).toBe(true)
|
||||
try {
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 })
|
||||
await expect(page.locator('style[data-dsh-custom-css="custom"]')).toBeAttached()
|
||||
// The injected CSS is live (a custom property the page can read back).
|
||||
const marker = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--dsh-e2e-marker').trim())
|
||||
expect(marker).toBe('1')
|
||||
} finally {
|
||||
await request.post(`${BASE_URL}/sidebar/api/settings.update`, {
|
||||
data: { patch: { titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false } },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -69,8 +69,15 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
// and 13px.
|
||||
expect(resolved.terminalFontFamily).toBe('')
|
||||
expect(resolved.terminalFontSize).toBe(13)
|
||||
// The position-compat mode defaults OFF (the normal layout is default),
|
||||
// with the strip defaulting to 40px.
|
||||
// The position-compat scheme is declared WITHOUT a schema default so a
|
||||
// stored document that predates it resolves without the field — the
|
||||
// CLIENT parsePrefs then applies the conservative `auto` default (or
|
||||
// migrates the legacy boolean), which is exactly what makes old
|
||||
// documents migrate instead of silently flipping to a scheme. The
|
||||
// legacy strip keeps its schema default of 40px.
|
||||
expect(resolved.titleBarScheme).toBeUndefined()
|
||||
expect(resolved.titleBarPresetId).toBeUndefined()
|
||||
expect(resolved.customCss).toBeUndefined()
|
||||
expect(resolved.titleBarCompat).toBe(false)
|
||||
expect(resolved.titleBarStripPx).toBe(40)
|
||||
// The enable-switch maps resolve to {} (everything on) for old documents.
|
||||
|
||||
+50
-13
@@ -45,6 +45,9 @@ describe('side card preferences', () => {
|
||||
editorExplorer: false,
|
||||
terminalShell: '',
|
||||
terminalShellArgs: '',
|
||||
titleBarScheme: 'auto',
|
||||
titleBarPresetId: '',
|
||||
customCss: '',
|
||||
titleBarCompat: false,
|
||||
titleBarStripPx: 40,
|
||||
htmlViewerNoSandbox: false,
|
||||
@@ -74,6 +77,9 @@ describe('side card preferences', () => {
|
||||
editorExplorer: false,
|
||||
terminalShell: '',
|
||||
terminalShellArgs: '',
|
||||
titleBarScheme: 'auto',
|
||||
titleBarPresetId: '',
|
||||
customCss: '',
|
||||
titleBarCompat: false,
|
||||
titleBarStripPx: 40,
|
||||
htmlViewerNoSandbox: false,
|
||||
@@ -103,6 +109,9 @@ describe('side card preferences', () => {
|
||||
editorExplorer: false,
|
||||
terminalShell: '',
|
||||
terminalShellArgs: '',
|
||||
titleBarScheme: 'auto',
|
||||
titleBarPresetId: '',
|
||||
customCss: '',
|
||||
titleBarCompat: false,
|
||||
titleBarStripPx: 40,
|
||||
htmlViewerNoSandbox: false,
|
||||
@@ -151,14 +160,42 @@ describe('side card preferences', () => {
|
||||
expect((await loadPrefs(wire({ editorExplorer: true }))).editorExplorer).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults titleBarCompat to false; only an explicit true turns the position-compat mode on', async () => {
|
||||
// Absent or malformed → off (the normal layout is the default).
|
||||
expect((await loadPrefs(wire({}))).titleBarCompat).toBe(false)
|
||||
expect((await loadPrefs(wire({ titleBarCompat: 'yes' }))).titleBarCompat).toBe(false)
|
||||
expect((await loadPrefs(wire({ titleBarCompat: 1 }))).titleBarCompat).toBe(false)
|
||||
// Explicit booleans survive verbatim.
|
||||
expect((await loadPrefs(wire({ titleBarCompat: false }))).titleBarCompat).toBe(false)
|
||||
expect((await loadPrefs(wire({ titleBarCompat: true }))).titleBarCompat).toBe(true)
|
||||
it('defaults the title-bar scheme to the conservative auto with no preset or custom CSS', async () => {
|
||||
// Absent or malformed → auto (plain web keeps the untouched layout).
|
||||
expect((await loadPrefs(wire({}))).titleBarScheme).toBe('auto')
|
||||
expect((await loadPrefs(wire({ titleBarScheme: 'weird' }))).titleBarScheme).toBe('auto')
|
||||
expect((await loadPrefs(wire({ titleBarScheme: 1 }))).titleBarScheme).toBe('auto')
|
||||
expect((await loadPrefs(wire({}))).titleBarPresetId).toBe('')
|
||||
expect((await loadPrefs(wire({ titleBarPresetId: 5 }))).titleBarPresetId).toBe('')
|
||||
expect((await loadPrefs(wire({}))).customCss).toBe('')
|
||||
expect((await loadPrefs(wire({ customCss: 7 }))).customCss).toBe('')
|
||||
// Valid values survive verbatim (including the explicit web scheme).
|
||||
const picked = await loadPrefs(wire({ titleBarScheme: 'preset', titleBarPresetId: 'dsh-desktop', customCss: 'html { }' }))
|
||||
expect(picked.titleBarScheme).toBe('preset')
|
||||
expect(picked.titleBarPresetId).toBe('dsh-desktop')
|
||||
expect(picked.customCss).toBe('html { }')
|
||||
expect((await loadPrefs(wire({ titleBarScheme: 'web' }))).titleBarScheme).toBe('web')
|
||||
})
|
||||
|
||||
it('migrates LEGACY documents that ALREADY HAVE VALUES into the custom scheme', async () => {
|
||||
// A pre-scheme document with the manual compat flag on maps to the
|
||||
// custom scheme, keeping the strip px the user chose.
|
||||
const migrated = await loadPrefs(wire({ titleBarCompat: true, titleBarStripPx: 56 }))
|
||||
expect(migrated.titleBarScheme).toBe('custom')
|
||||
expect(migrated.titleBarStripPx).toBe(56)
|
||||
// A non-default strip px alone (only reachable through the old gear
|
||||
// popup) also counts as "already has values" → custom.
|
||||
const stripOnly = await loadPrefs(wire({ titleBarStripPx: 48 }))
|
||||
expect(stripOnly.titleBarScheme).toBe('custom')
|
||||
expect(stripOnly.titleBarStripPx).toBe(48)
|
||||
// A stored scheme always wins over the legacy fields (round-trip of the
|
||||
// mirrored write: preset stays preset even though the mirror is true).
|
||||
const roundTrip = await loadPrefs(wire({ titleBarScheme: 'preset', titleBarCompat: true }))
|
||||
expect(roundTrip.titleBarScheme).toBe('preset')
|
||||
// Legacy off / absent / default strip → the conservative auto scheme.
|
||||
expect((await loadPrefs(wire({ titleBarCompat: false }))).titleBarScheme).toBe('auto')
|
||||
expect((await loadPrefs(wire({ titleBarStripPx: 40 }))).titleBarScheme).toBe('auto')
|
||||
expect((await loadPrefs(wire({}))).titleBarScheme).toBe('auto')
|
||||
})
|
||||
|
||||
it('defaults titleBarStripPx to 40 and clamps stored values into the contract range', async () => {
|
||||
@@ -223,9 +260,9 @@ describe('side card preferences', () => {
|
||||
const store = createSidebarStore()
|
||||
// Node environment: no window → the width falls back to PANEL_DEFAULT,
|
||||
// while the open flag still follows the preference.
|
||||
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setSession('fresh-session')
|
||||
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
const snapshot = store.getSnapshot()
|
||||
expect(snapshot.sessionId).toBe('fresh-session')
|
||||
expect(snapshot.state?.panelOpen).toBe(false)
|
||||
@@ -261,7 +298,7 @@ describe('side card preferences', () => {
|
||||
|
||||
it('skips the default seed tab when the editor (files window) type is disabled', () => {
|
||||
const store = createSidebarStore()
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: { editor: false }, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: { editor: false }, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setSession('no-editor')
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
|
||||
@@ -271,7 +308,7 @@ describe('side card preferences', () => {
|
||||
// editorExplorer modes.
|
||||
for (const editorExplorer of [true, false]) {
|
||||
const openStore = createSidebarStore()
|
||||
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
openStore.setSession(`with-editor-${editorExplorer}`)
|
||||
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
expect(openTabs.map(tab => tab.type)).toEqual(['editor'])
|
||||
@@ -281,7 +318,7 @@ describe('side card preferences', () => {
|
||||
it('seeds the empty editor home tab (files window) in both editorExplorer modes', () => {
|
||||
for (const editorExplorer of [true, false]) {
|
||||
const store = createSidebarStore()
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setSession(`fresh-${editorExplorer}`)
|
||||
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
expect(tabs).toHaveLength(1)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Built-in shell-preset data tests: registry integrity (unique ids, non-empty
|
||||
* titles, pure strip/detect functions) and the anywhere-labs DSH Desktop
|
||||
* entry's strip values (darwin advanced 20px, win32 advanced 32px as the
|
||||
* no-WCO fallback, compatibility/plain browser nothing).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getShellPreset, getShellPresets, presetStripFor } from '../src/client/shell-presets.ts'
|
||||
import type { DesktopEnv } from '../src/client/desktop-env.ts'
|
||||
|
||||
const env = (partial: Partial<DesktopEnv>): DesktopEnv => ({
|
||||
desktop: false,
|
||||
mode: null,
|
||||
platform: null,
|
||||
titlebarInset: 0,
|
||||
...partial,
|
||||
})
|
||||
|
||||
describe('shell presets', () => {
|
||||
it('keeps the registry well-formed (unique ids, titles, pure strip functions)', () => {
|
||||
const presets = getShellPresets()
|
||||
expect(presets.length).toBeGreaterThan(0)
|
||||
const ids = new Set<string>()
|
||||
for (const preset of presets) {
|
||||
expect(ids.has(preset.id)).toBe(false)
|
||||
ids.add(preset.id)
|
||||
expect(preset.title.length).toBeGreaterThan(0)
|
||||
expect(preset.desc.length).toBeGreaterThan(0)
|
||||
// The strip function must be total — an unknown environment yields
|
||||
// undefined, never a throw.
|
||||
expect(() => presetStripFor(preset, env({ desktop: false, mode: null, platform: null }))).not.toThrow()
|
||||
expect(() => preset.detect?.(env({ desktop: false, mode: null, platform: null }))).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('looks up presets by id; unknown/empty ids resolve to undefined', () => {
|
||||
expect(getShellPreset('dsh-desktop')).toBeDefined()
|
||||
expect(getShellPreset('')).toBeUndefined()
|
||||
expect(getShellPreset('no-such-shell')).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('dsh-desktop (DeepSeek Harness Desktop, anywhere-labs)', () => {
|
||||
const preset = getShellPreset('dsh-desktop')!
|
||||
|
||||
it('reserves 20px on darwin advanced (the caption row above the surfaces)', () => {
|
||||
expect(presetStripFor(preset, env({ desktop: true, mode: 'advanced', platform: 'darwin' }))).toBe(20)
|
||||
})
|
||||
|
||||
it('reserves 32px on win32 advanced as the no-WCO fallback', () => {
|
||||
expect(presetStripFor(preset, env({ desktop: true, mode: 'advanced', platform: 'win32' }))).toBe(32)
|
||||
})
|
||||
|
||||
it('reserves nothing in compatibility mode or on unknown platforms', () => {
|
||||
expect(presetStripFor(preset, env({ desktop: true, mode: 'compatibility', platform: 'win32' }))).toBeUndefined()
|
||||
expect(presetStripFor(preset, env({ desktop: true, mode: 'advanced', platform: 'linux' }))).toBeUndefined()
|
||||
expect(presetStripFor(preset, env({ desktop: false, mode: null, platform: null }))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('detects advanced shells only as a SUGGESTION signal (never auto-applies)', () => {
|
||||
expect(preset.detect?.(env({ desktop: true, mode: 'advanced', platform: 'darwin' }))).toBe(true)
|
||||
expect(preset.detect?.(env({ desktop: true, mode: 'advanced', platform: 'win32' }))).toBe(true)
|
||||
expect(preset.detect?.(env({ desktop: true, mode: 'compatibility', platform: 'win32' }))).toBe(false)
|
||||
expect(preset.detect?.(env({ desktop: false, mode: null, platform: null }))).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -125,8 +125,10 @@ describe('SideCardSection declarative inventory', () => {
|
||||
const html = renderSection(store, service)
|
||||
// Subagents declares a toggle → its card carries the settings gear
|
||||
// (aria-label = "<title> Feature settings"); Explorer and Image declare
|
||||
// none → no gear.
|
||||
// none → no gear. The position-compat row is a DROPDOWN (no gear unless
|
||||
// the custom scheme is active), so the total is 1.
|
||||
expect(html.match(/aria-label="[^"]*Feature settings"/g)?.length).toBe(1)
|
||||
expect(html).toContain('Subagents Feature settings')
|
||||
})
|
||||
|
||||
it('renders the two dashed "add plugin" cards (tab grid + viewer grid)', () => {
|
||||
@@ -159,28 +161,35 @@ describe('SideCardSection declarative inventory', () => {
|
||||
const { store, service } = mount()
|
||||
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { subagent: false } })
|
||||
const html = renderSection(store, service)
|
||||
// The disabled Subagents card loses its gear; the position-compat row
|
||||
// is a dropdown (default auto → no gear either).
|
||||
expect(html).not.toContain('Feature settings')
|
||||
})
|
||||
|
||||
it('renders the position-compat mode general row: off by default, checked when the pref is on', () => {
|
||||
it('renders the position-compat mode row as a scheme dropdown: auto default, custom keeps the gear', () => {
|
||||
const { store, service } = mount()
|
||||
let html = renderSection(store, service)
|
||||
// The general row renders its title and description.
|
||||
// The general row renders its title and description; the scheme is the
|
||||
// conservative auto by default and the row is the shared SelectMenu
|
||||
// dropdown (the closed anchor shows the picked option — NOT a native
|
||||
// <select>).
|
||||
expect(html).toContain('Position compatibility mode')
|
||||
expect(html).toContain('Reserve space for the native Windows title bar')
|
||||
// Three general rows now: only interceptOpenPath is checked by default
|
||||
// (openByDefault defaults off, the titleBarCompat row starts UNCHECKED) —
|
||||
// the checked checkbox count is 1 while the total checkbox count is 3.
|
||||
expect(html.match(/type="checkbox"/g)?.length).toBe(3)
|
||||
expect(html).toContain('Pick the title-bar compatibility scheme: auto-detect (default, conservative) / DSH official web / known desktop shells / custom (shift distance + custom CSS)')
|
||||
expect(html).not.toContain('<select')
|
||||
expect(html).toContain('>Auto-detect<')
|
||||
// Two general-row switches remain (openByDefault + interceptOpenPath),
|
||||
// only interceptOpenPath checked by default — the scheme row is a
|
||||
// dropdown, not a switch.
|
||||
expect(html.match(/type="checkbox"/g)?.length).toBe(2)
|
||||
expect(html.match(/checked=""/g)?.length).toBe(1)
|
||||
// The row's gear (customize the shift distance) is dormant while the
|
||||
// mode is off — the feature-card convention.
|
||||
// Auto (default) needs no further settings → no gear.
|
||||
expect(html).not.toContain('Position compatibility mode Feature settings')
|
||||
|
||||
// When the pref is on, the new switch is checked and the gear appears.
|
||||
store.setPrefs({ ...store.getPrefs(), titleBarCompat: true })
|
||||
// The custom scheme keeps its settings button (the px + CSS popup);
|
||||
// the anchor now shows the picked custom option.
|
||||
store.setPrefs({ ...store.getPrefs(), titleBarScheme: 'custom', titleBarCompat: true })
|
||||
html = renderSection(store, service)
|
||||
expect(html.match(/checked=""/g)?.length).toBe(2)
|
||||
expect(html).toContain('>Custom<')
|
||||
expect(html).toContain('aria-label="Position compatibility mode Feature settings"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* The title-bar strip resolution chain: standard WCO geometry first
|
||||
* (authoritative even when 0), then the documented URL inset contract
|
||||
* parameter, then the opt-in preset / custom scheme — and 0 (plain web,
|
||||
* nothing modified) otherwise. No per-shell branch lives in the core.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { computeTitleBarStrip } from '../src/client/titlebar-strip.ts'
|
||||
import type { DesktopEnv } from '../src/client/desktop-env.ts'
|
||||
import type { WcoSnapshot } from '../src/client/wco.ts'
|
||||
import type { ShellPreset } from '../src/client/shell-presets.ts'
|
||||
|
||||
const env = (partial: Partial<DesktopEnv>): DesktopEnv => ({
|
||||
desktop: false,
|
||||
mode: null,
|
||||
platform: null,
|
||||
titlebarInset: 0,
|
||||
...partial,
|
||||
})
|
||||
|
||||
const wco = (present: boolean, height = 0): WcoSnapshot => ({ present, height })
|
||||
|
||||
const noPreset: ShellPreset | undefined = undefined
|
||||
|
||||
describe('computeTitleBarStrip', () => {
|
||||
it('keeps plain web untouched: no WCO, no inset, no scheme → 0', () => {
|
||||
expect(computeTitleBarStrip(env({}), wco(false), 'auto', noPreset, 40)).toBe(0)
|
||||
expect(computeTitleBarStrip(env({}), wco(false), 'custom', noPreset, 40)).toBe(40)
|
||||
})
|
||||
|
||||
it('the explicit WEB scheme forces 0 — not even standard WCO geometry applies', () => {
|
||||
const desktop = env({ desktop: true, mode: 'advanced', platform: 'win32' })
|
||||
// The user declared "DSH official web": no adaptation at all, even when
|
||||
// a real overlay exists or a preset is active.
|
||||
expect(computeTitleBarStrip(desktop, wco(true, 36), 'web', noPreset, 40)).toBe(0)
|
||||
expect(computeTitleBarStrip(desktop, wco(false), 'web', noPreset, 40)).toBe(0)
|
||||
expect(computeTitleBarStrip(desktop, wco(false), 'web', {
|
||||
id: 't', title: 't', desc: '', stripFor: () => 20,
|
||||
}, 40)).toBe(0)
|
||||
})
|
||||
|
||||
it('trusts the standard WCO geometry first in every scheme (even 0 — e.g. maximized)', () => {
|
||||
const desktop = env({ desktop: true, mode: 'advanced', platform: 'win32' })
|
||||
expect(computeTitleBarStrip(desktop, wco(true, 36), 'auto', noPreset, 0)).toBe(36)
|
||||
expect(computeTitleBarStrip(desktop, wco(true, 36), 'preset', noPreset, 0)).toBe(36)
|
||||
expect(computeTitleBarStrip(desktop, wco(true, 36), 'custom', noPreset, 40)).toBe(36)
|
||||
// Authoritative zero: the overlay is hidden while maximized — no strip.
|
||||
expect(computeTitleBarStrip(desktop, wco(true, 0), 'auto', noPreset, 40)).toBe(0)
|
||||
})
|
||||
|
||||
it('applies the documented URL inset contract parameter before any scheme', () => {
|
||||
const stamped = env({ desktop: true, mode: 'advanced', platform: 'darwin', titlebarInset: 20 })
|
||||
expect(computeTitleBarStrip(stamped, wco(false), 'auto', noPreset, 0)).toBe(20)
|
||||
expect(computeTitleBarStrip(stamped, wco(false), 'preset', noPreset, 0)).toBe(20)
|
||||
})
|
||||
|
||||
it('uses the opt-in preset strip under the preset scheme only', () => {
|
||||
const preset: ShellPreset = {
|
||||
id: 't', title: 't', desc: '',
|
||||
stripFor: (e) => e.platform === 'darwin' ? 20 : undefined,
|
||||
}
|
||||
const darwin = env({ desktop: true, mode: 'advanced', platform: 'darwin' })
|
||||
expect(computeTitleBarStrip(darwin, wco(false), 'preset', preset, 0)).toBe(20)
|
||||
// Auto never applies the preset — only standard signals.
|
||||
expect(computeTitleBarStrip(darwin, wco(false), 'auto', preset, 0)).toBe(0)
|
||||
// Custom never applies the preset either.
|
||||
expect(computeTitleBarStrip(darwin, wco(false), 'custom', preset, 0)).toBe(0)
|
||||
})
|
||||
|
||||
it('uses the manual strip px under the custom scheme only', () => {
|
||||
expect(computeTitleBarStrip(env({}), wco(false), 'custom', noPreset, 56)).toBe(56)
|
||||
expect(computeTitleBarStrip(env({}), wco(false), 'auto', noPreset, 56)).toBe(0)
|
||||
expect(computeTitleBarStrip(env({}), wco(false), 'preset', noPreset, 56)).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Window Controls Overlay (WCO) geometry store tests: feature detection,
|
||||
* subscribe/getSnapshot, geometrychange re-reads, and the absent-API
|
||||
* fallback (the conservative "auto" signal — plain browsers and non-overlay
|
||||
* shells report NONE and the sidebar adapts nothing).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import './browser-globals.ts'
|
||||
import {
|
||||
getWcoSnapshot, setWcoSourceForTests, subscribeWco, WCO_NONE,
|
||||
type WcoSource,
|
||||
} from '../src/client/wco.ts'
|
||||
|
||||
/** A controllable fake of the standard API surface. */
|
||||
function makeSource(initial: { width: number; height: number; visible?: boolean }): {
|
||||
source: WcoSource
|
||||
setRect: (width: number, height: number) => void
|
||||
fireGeometryChange: () => void
|
||||
listeners: Set<() => void>
|
||||
} {
|
||||
const listeners = new Set<() => void>()
|
||||
let rect = { x: 0, y: 0, width: initial.width, height: initial.height }
|
||||
const source: WcoSource = {
|
||||
visible: initial.visible ?? true,
|
||||
getTitlebarAreaRect: () => rect,
|
||||
addEventListener: (type, listener) => { if (type === 'geometrychange') listeners.add(listener) },
|
||||
removeEventListener: (type, listener) => { if (type === 'geometrychange') listeners.delete(listener) },
|
||||
}
|
||||
return {
|
||||
source,
|
||||
setRect: (width, height) => { rect = { x: 0, y: 0, width, height } },
|
||||
fireGeometryChange: () => { for (const listener of listeners) listener() },
|
||||
listeners,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setWcoSourceForTests(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setWcoSourceForTests(undefined)
|
||||
})
|
||||
|
||||
describe('wco geometry store', () => {
|
||||
it('reports NONE (absent API) in plain browsers / non-overlay shells', () => {
|
||||
expect(getWcoSnapshot()).toBe(WCO_NONE)
|
||||
expect(getWcoSnapshot().present).toBe(false)
|
||||
expect(getWcoSnapshot().height).toBe(0)
|
||||
})
|
||||
|
||||
it('reads the real overlay rect once a source is present', () => {
|
||||
const { source } = makeSource({ width: 138, height: 36 })
|
||||
setWcoSourceForTests(source)
|
||||
expect(getWcoSnapshot()).toEqual({ present: true, height: 36 })
|
||||
})
|
||||
|
||||
it('rounds fractional overlay heights to whole pixels', () => {
|
||||
const { source } = makeSource({ width: 100, height: 35.6 })
|
||||
setWcoSourceForTests(source)
|
||||
expect(getWcoSnapshot().height).toBe(36)
|
||||
})
|
||||
|
||||
it('re-reads geometry on geometrychange (maximize/restore) and notifies subscribers', () => {
|
||||
const { source, setRect, fireGeometryChange, listeners } = makeSource({ width: 138, height: 32 })
|
||||
setWcoSourceForTests(source)
|
||||
const calls: number[] = []
|
||||
const off = subscribeWco(() => { calls.push(getWcoSnapshot().height) })
|
||||
// Maximized: the overlay reports a zero/empty rect.
|
||||
setRect(0, 0)
|
||||
fireGeometryChange()
|
||||
expect(getWcoSnapshot()).toEqual({ present: true, height: 0 })
|
||||
expect(calls).toEqual([0])
|
||||
// Restored.
|
||||
setRect(138, 36)
|
||||
fireGeometryChange()
|
||||
expect(getWcoSnapshot()).toEqual({ present: true, height: 36 })
|
||||
expect(calls).toEqual([0, 36])
|
||||
// The disposer stops notifications.
|
||||
off()
|
||||
setRect(138, 40)
|
||||
fireGeometryChange()
|
||||
expect(calls).toEqual([0, 36])
|
||||
expect(listeners.size).toBe(0)
|
||||
})
|
||||
|
||||
it('treats a PHANTOM API (present but not visible) as absent — headless/macOS expose the interface with no overlay', () => {
|
||||
const { source } = makeSource({ width: 0, height: 0, visible: false })
|
||||
setWcoSourceForTests(source)
|
||||
// present=false lets the strip chain fall through to the inset / preset
|
||||
// / custom scheme instead of trusting an empty rect.
|
||||
expect(getWcoSnapshot()).toEqual({ present: false, height: 0 })
|
||||
// A real overlay flips to authoritative immediately.
|
||||
const real = makeSource({ width: 138, height: 36, visible: true })
|
||||
setWcoSourceForTests(real.source)
|
||||
expect(getWcoSnapshot()).toEqual({ present: true, height: 36 })
|
||||
})
|
||||
|
||||
it('treats a throwing API as absent (never breaks the layout)', () => {
|
||||
const source: WcoSource = {
|
||||
visible: true,
|
||||
getTitlebarAreaRect: () => { throw new Error('racy API') },
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
}
|
||||
setWcoSourceForTests(source)
|
||||
expect(getWcoSnapshot()).toEqual({ present: false, height: 0 })
|
||||
})
|
||||
|
||||
it('re-attaches cleanly when the source is swapped (test hook)', () => {
|
||||
const first = makeSource({ width: 138, height: 32 })
|
||||
const second = makeSource({ width: 138, height: 36 })
|
||||
setWcoSourceForTests(first.source)
|
||||
expect(first.listeners.size).toBe(0) // no subscriber yet
|
||||
const off = subscribeWco(() => {})
|
||||
expect(first.listeners.size).toBe(1)
|
||||
setWcoSourceForTests(second.source)
|
||||
expect(first.listeners.size).toBe(0)
|
||||
expect(second.listeners.size).toBe(1)
|
||||
expect(getWcoSnapshot().height).toBe(36)
|
||||
off()
|
||||
setWcoSourceForTests(undefined)
|
||||
expect(getWcoSnapshot()).toBe(WCO_NONE)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user