diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index d9070d4..c436d8d 100644 --- a/docs/external-plugin-guide.md +++ b/docs/external-plugin-guide.md @@ -753,7 +753,7 @@ ctx.effect(() => - **面板表面**:右/底面板背景 = `var(--dsw-alias-bg-layer-1)`。**绝不消费 `--dsw-specific-sidebar-fill`**(宿主左导航专属,皮肤按左导航语义覆盖它,面板消费会失去填充)。换面板表面 = 覆写 `--dsw-alias-bg-layer-1`。 - **终端/编辑器表面**:`effectiveTokenValue` 读 `--dsw-alias-bg-base`——`transparent` 与 alpha < 0.9 的半透明值回退不透明底色(文字不叠背景画,issue #90);≥ 0.9 放行。 - **根锚点**:宿主 div 带 `data-dsh-better-sidebar`(append 到 body);其内**面板宿主层** `[data-dsh-panel-host]`(`fixed; inset:0; z-25; pointer-events:none`,v0.13.1+),面板/开关簇 absolute 定位,免疫中间层 transform 劫持;页面级 transform 触发 `data-dsh-panel-host-degraded` 降级。皮肤作用域覆盖限定在 `[data-dsh-better-sidebar]` 内。 -- **布局变量**(`` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`。宿主推挤 = `#root` margin/width calc + centerCol `margin-bottom`;锚点 = **JS 标注**(禁止 `nth-child`):侧栏 shell 的定位器给测得的 centerCol 节点打 `[data-dsh-center-col]` 标签(`Sidebar.tsx` locate,节点更换/HMR 时随 ref 迁移),`layout.css` 用 `#root [data-dsh-center-col]` 选中(`drag-layout.e2e.ts` 断言恰一节点且为 `[data-slot="conversation"]` 的父级)。 +- **布局变量**(`` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`。右面板宽度 = AppFrame 的 `padding-right` 预留(新版 `#root [data-dsh-frame]` / rc.8 `#root > [data-slot="root"] > div` 双锚点),AppFrame border box 保持完整桌面视口宽度(Harness 以此判定桌面/窄屏布局,避免插件面板展开误入窄屏);AppFrame 的 details 拖拽手柄按同一变量向左平移贴合列边缘。底部面板仍走 centerCol `margin-bottom`;centerCol 锚点 = **JS 标注**(禁止 `nth-child`):侧栏 shell 的定位器给测得的 centerCol 节点打 `[data-dsh-center-col]` 标签(`Sidebar.tsx` locate,节点更换/HMR 时随 ref 迁移),`layout.css` 用 `#root [data-dsh-center-col]` 选中(`drag-layout.e2e.ts` 断言恰一节点且为 `[data-slot="conversation"]` 的父级;frame 宽度与桌面 Session Log 由 `desktop-layout.e2e.ts` 断言)。 - **桌面信号与标题栏**(v0.14.1+ 四方案模型 `SidebarPrefs.titleBarScheme`,唯一决策点 `src/client/titlebar-strip.ts` 纯函数): - 壳信号(只读,不自动触发修改):URL `dsh-desktop-mode` / `dsh-desktop-platform` / 可选 `dsh-desktop-titlebar-inset`(0–120 clamp)。 - **strip 取值链**:⓪ `web` 方案强制 0;① `navigator.windowControlsOverlay` 真实几何(`wco.ts` 订阅 `geometrychange`,**为 0 也权威**,`visible=false` 幽灵 API 视为缺失);② URL inset;③ 壳预设 `stripFor`(仅 `preset`);④ 手动 `titleBarStripPx`(仅 `custom`);⑤ 0。驱动 `body[data-dsh-title-bar-compat]` + `--dsh-title-bar-strip`。 diff --git a/docs/plans/2026-08-19-sidebar-injection-unified-host-design.md b/docs/plans/2026-08-19-sidebar-injection-unified-host-design.md index 24f4885..a37b8b3 100644 --- a/docs/plans/2026-08-19-sidebar-injection-unified-host-design.md +++ b/docs/plans/2026-08-19-sidebar-injection-unified-host-design.md @@ -259,3 +259,4 @@ document.body - 本地 Node 24 跑 `test:mount` 需 `node --expose-internals` 启动 dsh CLI(HMR 服务要求,CI Node 22 无此问题)——本地用 DSH_CMD 包装,不改仓库脚本。 - **CR #232 修复(2026-08-19)**:① 降级同步改为按**未修正几何**判定(跟踪自身补偿量,祖先 transform 消失才退出循环);② chunk 重验证成为 `loadChunk` 屏障(重验证挂起期间不提供缓存,杜绝 HMR 竞态陈旧 exports);③ 键盘 inset 公式补 `visualViewport.offsetTop` 并监听 `scroll`。 - **z-index 40→25(2026-08-22)**:设计初稿的"30–99 无任何 DSH 元素"(源自 #52 修复记录)低估了官方 ui-cordis 的动态插件面板(fixed z-index 30,挂 `sidebar.footer.action` 槽)——better-sidebar 底部面板曾盖住其清单/审批面。下调宿主层至 25(AppFrame overlayLayer 20 之上、ui-cordis 30 与 DSH 浮层栈 100+ 之下),折叠按钮簇 45 保留为 host 内部层叠;`AGENTS.md` §8 与 `2026-08-15-skin-compat-design.md` 已同步。 + - **桌面布局判定修正(2026-08-26)**:`#root` 的 width/margin 推挤会缩小 AppFrame 的 border box,触发 Harness `<1024px` 窄屏判定,使桌面左侧栏自动折叠、Session Log 退化为 fixed icon,并挤乱后台任务操作区。右面板宽度改为 AppFrame 内部 `padding-right`(新版 `[data-dsh-frame]` 与 rc.8 root-slot direct child 双锚点),Frame 与 `#root` 保持视口宽度;details 拖拽手柄按同一变量平移。`desktop-layout.e2e.ts` 守护 frame 宽度、桌面 Session Log 文本按钮与面板不重叠,`drag-layout.e2e.ts` 继续守护拖拽 1:1。 diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 7447806..fa5f205 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -316,7 +316,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // session header's right-aligned utilities (the "Session log" download // capsule) must yield. layout.css keys off this body attribute to push the // header's right padding out past the cluster. Only the CLOSED panel needs - // it — an open panel already squeezes `#root` left, moving the header clear. + // it — an open panel reserves AppFrame padding, moving the header clear. const collapsed = state === undefined || !state.panelOpen useEffect(() => { if (collapsed) document.body.setAttribute('data-dsh-sidebar-collapsed', '') @@ -703,9 +703,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // from the AppFrame's center column DOM (the parent of the // [data-slot="conversation"] wrapper — layout.css's center column) so the // bottom panel tracks the column's real - // horizontal edges — including the animated margin-right push while the - // right panel opens/closes; a frame that never appears keeps the initial - // zero-size fallback (the panel renders at 0 width until measured). + // horizontal edges — including the animated AppFrame padding reservation + // while the right panel opens/closes; a frame that never appears keeps the + // initial zero-size fallback (the panel renders at 0 width until measured). // The rect lives in a REF (not state): the open/close transition resizes // the center column EVERY frame for its duration, and reacting per frame // with setState re-renders the whole Sidebar (every mounted tab) at @@ -1708,8 +1708,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // Direct from the center column's measured right edge: the bottom // panel spans ONLY the center column, ending exactly at the // details column's left edge (the details column sits between the - // center and the right panel, and the right panel's margin-right - // push is already baked into centerRect.right). + // center and the right panel, and the right panel's reserved frame + // padding is already baked into centerRect.right). right: window.innerWidth - centerRectRef.current.right, // The seam against the open right panel needs its own hairline // (the right panel's border-left alone is covered by this panel's diff --git a/src/client/layout.css b/src/client/layout.css index c31a94a..19347eb 100644 --- a/src/client/layout.css +++ b/src/client/layout.css @@ -1,38 +1,42 @@ /** - * Layout push: when a panel is open it OCCUPIES the layout instead of - * floating over it — the app shell (#root, the AppFrame three-column grid) - * gives up space. Only the center column is flexible (1fr), so the right - * panel's width squeeze (margin-right on #root) lands exactly on the - * conversation output and the input bar, like a VSCode sidebar. + * Layout push: an open panel occupies layout instead of floating over it. + * The right panel reserves padding inside the AppFrame three-column grid, + * so the app sidebar, conversation, and details column all remain to its + * left while the frame's border-box stays viewport-wide. Harness measures + * that border-box to choose desktop versus narrow presentation; preserving + * it prevents a desktop window from entering narrow mode merely because the + * plugin panel opened. * - * The width rides `calc(100% - var(...))` instead of a bare margin on a - * full-width box: some desktop shells (DSH Desktop, #208) set #root to - * width:100%, where a margin would overflow the viewport additively — - * the calc keeps the box at exactly 100% minus the push in every shell. - * Width and margin transition in lockstep (same variable, same duration - * and easing), so expand/collapse animates the content width exactly as - * the bare-margin version did. + * AppFrame positions its details drag handle from the measured border-box. + * Move that handle left by the same reserved width so it stays on the details + * column edge. The ordinary sidebar handle remains at its native coordinate. * - * The bottom panel squeezes ONLY the center column — it must not cover the - * app's own left sidebar or the right panel. The anchor is the frame's - * center grid item via its stable data attribute ([data-pane="conversation"] - * on the centerCol inside [data-dsh-frame]), not a positional path: the - * frame's child order also contains an overlay layer and drag handles - * (verified against a live DSH 0.1.x page), so nth-child is brittle. A - * stretched grid item shrinks by its margins, so the conversation content - * (output + input bar) lifts without touching the sidebars. + * The bottom panel squeezes only the conversation column. The anchor is the + * [data-dsh-center-col] tag the Sidebar shell's locator writes on the + * frame's measured center grid item (Sidebar.tsx locate — not a positional + * path: the frame also contains an overlay layer and drag handles). A + * stretched grid item shrinks by its bottom margin, so the conversation + * output and composer lift without touching either sidebar. * - * The sizes ride CSS variables updated by the Sidebar shell (0 while - * collapsed); expand/collapse animates both the margins and the panel - * slides on the same theme duration. Drags disable the transition so the - * layout tracks the pointer. + * Sizes ride CSS variables updated by the Sidebar shell (0 while collapsed). + * Expand/collapse uses the host theme duration; drags disable transitions so + * every reserved edge tracks the pointer. */ -#root { - margin-right: var(--dsh-sidebar-width, 0px); - width: calc(100% - var(--dsh-sidebar-width, 0px)); +/* Current shells expose [data-dsh-frame]; rc.8-era shells expose the same + AppFrame only as the root slot's direct child. */ +#root [data-dsh-frame], +#root > [data-slot="root"] > div { + box-sizing: border-box; + padding-right: var(--dsh-sidebar-width, 0px); + transition: padding-right var(--ds-transition-duration-slow) var(--ds-ease-in-out); +} + +#root [data-dsh-frame] > [data-side="details"], +#root > [data-slot="root"] > div > [data-side="details"] { + transform: translateX(calc(0px - var(--dsh-sidebar-width, 0px))); transition: - margin-right var(--ds-transition-duration-slow) var(--ds-ease-in-out), - width var(--ds-transition-duration-slow) var(--ds-ease-in-out); + left var(--ds-transition-duration-slow) var(--ds-ease-in-out), + transform var(--ds-transition-duration-slow) var(--ds-ease-in-out); } /* The AppFrame's center column. The Sidebar shell's locator (Sidebar.tsx @@ -69,8 +73,13 @@ body[data-dsh-sidebar-collapsed] [data-slot="conversation.session.header"] > hea padding-right: 78px; } -body[data-dsh-sidebar-dragging] #root, -body[data-dsh-sidebar-dragging] #root [data-dsh-center-col] { +body[data-dsh-sidebar-dragging] #root [data-dsh-frame], +body[data-dsh-sidebar-dragging] #root > [data-slot="root"] > div, +body[data-dsh-sidebar-dragging] #root [data-dsh-frame] > [data-side="details"], +body[data-dsh-sidebar-dragging] #root > [data-slot="root"] > div > [data-side="details"], +body[data-dsh-sidebar-dragging] #root [data-dsh-center-col], +#root [data-dsh-frame][data-dragging] > [data-side="details"], +#root > [data-slot="root"] > div[data-dragging] > [data-side="details"] { transition: none; } @@ -94,7 +103,10 @@ body[data-dsh-sidebar-dragging] #root [data-dsh-center-col] { } @media (prefers-reduced-motion: reduce) { - #root, + #root [data-dsh-frame], + #root > [data-slot="root"] > div, + #root [data-dsh-frame] > [data-side="details"], + #root > [data-slot="root"] > div > [data-side="details"], #root [data-dsh-center-col] { transition: none; } diff --git a/tests/e2e/desktop-layout.e2e.ts b/tests/e2e/desktop-layout.e2e.ts new file mode 100644 index 0000000..0c8d797 --- /dev/null +++ b/tests/e2e/desktop-layout.e2e.ts @@ -0,0 +1,112 @@ +/** Desktop layout regression: opening the right panel must not make Harness enter narrow mode. */ +import { mkdirSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { expect, test, type Page } from '@playwright/test' +import { PAGE_URL } from './host' + +const WORKSPACE_PATH = process.env.DSH_E2E_DESKTOP_WORKSPACE + ?? (process.env.DSH_E2E_WORKSPACE === undefined + ? join(homedir(), 'dsh-e2e-desktop-layout-workspace') + : `${process.env.DSH_E2E_WORKSPACE}-desktop-layout`) + +async function dismissOnboarding(page: Page): Promise { + await expect + .poll(() => page.getByRole('button', { name: /^(Continue|Configure later|继续|稍后配置)$/ }).count(), { timeout: 60_000 }) + .toBeGreaterThan(0) + for (let round = 0; round < 8; round++) { + let dismissed = false + for (const name of [/^(Continue|继续)$/, /^(Configure later|稍后配置)$/]) { + const button = page.getByRole('button', { name }).first() + if ((await button.count()) === 0) continue + await button.click() + dismissed = true + await expect(button).toBeHidden() + } + if (!dismissed) return + } + throw new Error('onboarding takeovers did not settle after visible dismissal') +} + +test.beforeAll(() => { + mkdirSync(WORKSPACE_PATH, { recursive: true }) +}) + +test('right panel keeps desktop session actions in their header positions', async ({ page }) => { + const pageErrors: string[] = [] + const consoleErrors: string[] = [] + const requestFailures: string[] = [] + const badResponses: string[] = [] + page.on('pageerror', error => pageErrors.push(error.stack ?? error.message)) + page.on('console', message => { if (message.type() === 'error') consoleErrors.push(message.text()) }) + page.on('requestfailed', request => requestFailures.push(`${request.method()} ${request.url()} ${request.failure()?.errorText ?? 'unknown'}`)) + page.on('response', response => { if (response.status() >= 400) badResponses.push(`${response.status()} ${response.url()}`) }) + + await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' }) + await dismissOnboarding(page) + + await page.getByRole('button', { name: /^(Choose workspace|选择工作区)$/ }).click() + const picker = page.getByRole('dialog', { name: /^(Select Workspace Directory|选择工作区目录)$/ }) + await expect(picker).toBeVisible() + await picker.getByRole('button', { name: /^(Edit path|编辑路径)$/ }).click() + const pathInput = picker.getByRole('textbox', { name: /^(Edit path|编辑路径)$/ }) + await pathInput.fill(WORKSPACE_PATH) + await pathInput.press('Enter') + await expect(pathInput).toBeHidden() + await picker.getByRole('button', { name: /^(Open|打开)$/ }).click() + + // Target the ACTIVE composer by its accessible name. DSH 0.1.2-alpha hosts + // keep an INERT `data-composer-input` "Choose workspace" textbox (with + // contenteditable="false") later in DOM order than the real composer — a + // bare `getByRole('textbox').last()` resolved to that one and fill() threw + // ("not an ,