Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions dsh-desktop-next/README.i18n.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Git blob hashes for the confirmed English and Chinese pair.
README.md: 4d763198c9d0b2ba75f5cefeb58bae9be4da981e
README.zh.md: 755a728184f97ea6a83ed08c6704a6a1bc25f49b
README.md: 4dd5443616243deb60c060016a30483b9762a2e4
README.zh.md: 3ce0bba3335686bd765661a0aba9d066f7cd302e
2 changes: 1 addition & 1 deletion dsh-desktop-next/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ corepack yarn workspace dsh-desktop-next verify:window-controls
corepack yarn workspace dsh-desktop-next verify:onboarding
```

Set `DSH_NEXT_TEST_BROWSER_CHANNEL=chrome` to use an installed Google Chrome instead. Screenshots are saved under `dsh-desktop-next/.desktop-next/verification/`. Native macOS window movement still needs manual verification. Next uses the official frontend's window drag ownership, interactive-control exclusions, modal portal geometry, and Electron region-recall watcher. The conversation header and plugin page headers own their draggable boxes. The official frame places the sidebar and new-session controls beside the macOS traffic lights. Next adds no title-row drag override, fixed caption band, sidebar-control positioning, or modal/fullscreen drag override.
Set `DSH_NEXT_TEST_BROWSER_CHANNEL=chrome` to use an installed Google Chrome instead. Screenshots are saved under `dsh-desktop-next/.desktop-next/verification/`. Native macOS window movement still needs manual verification. Next uses the official frontend's window drag ownership, interactive-control exclusions, modal portal geometry, and Electron region-recall watcher. The conversation header and plugin page headers own their draggable boxes. The official frame places the sidebar and new-session controls beside the macOS traffic lights. Next adds no title-row drag override, fixed caption band, sidebar-control positioning, or modal/fullscreen drag override. The one exception is the content of the scrollers below those rows: Chromium collects app-region boxes without clipping them, so a control scrolled beneath a row would subtract its whole box. `src/client/styles.ts` removes those boxes and the unpainted part of the collapsed Plugins head, and `verify:window-controls` checks each row with a control run on the official rules alone.

## App icon resources

Expand Down
2 changes: 1 addition & 1 deletion dsh-desktop-next/README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ corepack yarn workspace dsh-desktop-next verify:window-controls
corepack yarn workspace dsh-desktop-next verify:onboarding
```

设置 `DSH_NEXT_TEST_BROWSER_CHANNEL=chrome` 可使用已安装的 Google Chrome。截图保存在 `dsh-desktop-next/.desktop-next/verification/`。macOS 原生窗口拖动仍需手工验证。Next 使用官方前端的窗口拖动区域归属、交互控件排除规则、弹窗 Portal 几何布局和 Electron 拖动区域重算监听器。会话顶栏和插件页顶栏各自持有拖动区域;官方框架把侧栏和新会话控件放在 macOS 红黄绿按钮旁。Next 不增加标题行拖动覆盖、固定标题带、侧栏控件定位或弹窗/全屏拖动覆盖。
设置 `DSH_NEXT_TEST_BROWSER_CHANNEL=chrome` 可使用已安装的 Google Chrome。截图保存在 `dsh-desktop-next/.desktop-next/verification/`。macOS 原生窗口拖动仍需手工验证。Next 使用官方前端的窗口拖动区域归属、交互控件排除规则、弹窗 Portal 几何布局和 Electron 拖动区域重算监听器。会话顶栏和插件页顶栏各自持有拖动区域;官方框架把侧栏和新会话控件放在 macOS 红黄绿按钮旁。Next 不增加标题行拖动覆盖、固定标题带、侧栏控件定位或弹窗/全屏拖动覆盖。唯一的例外是这些行下方滚动区里的内容:Chromium 收集拖动区域时不按滚动容器裁剪,滚到行下面的控件会把自己的整个盒子从行里减掉。`src/client/styles.ts` 去掉这些盒子以及插件页顶栏收起后未绘制的部分,`verify:window-controls` 对每一行都带一组只用官方规则的对照。

## 应用图标资源

Expand Down
103 changes: 101 additions & 2 deletions dsh-desktop-next/scripts/verify-window-controls.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,75 @@ try {
assert.ok(geometry.width > 300 && geometry.height >= 30, JSON.stringify(geometry))
assert.equal(await page.locator('[data-next-window-controls]').count(), 0, 'No extra visible titlebar or overlay component')
}
// Chromium collects app-region boxes without clipping them, and the window lets the last box containing
// a point decide it.
const installDragRegions = () => page.evaluate(() => {
globalThis.__nextDragRegions = points => {
const regions = [...document.querySelectorAll('*')].flatMap(element => {
const style = getComputedStyle(element)
const region = style.getPropertyValue('-webkit-app-region')
if (region === 'none' || style.visibility !== 'visible' || ['none', 'contents', 'inline'].includes(style.display)) return []
const box = element.getBoundingClientRect()
const collected = [{ region, box }]
// The one generated box this reads: the part of the Plugins head that does not paint.
const trim = getComputedStyle(element, '::after')
if (element.hasAttribute('data-window-drag') && !['none', 'normal'].includes(trim.content)) {
collected.push({ region: trim.getPropertyValue('-webkit-app-region'),
box: { left: box.left, right: box.right, top: box.bottom - Number.parseFloat(trim.height), bottom: box.bottom } })
}
return collected
})
return points.map(([x, y]) => regions.reduce((decided, { region, box }) =>
x >= box.left && x < box.right && y >= box.top && y < box.bottom ? region : decided, 'none'))
}
})
// A control inside a scroller whose box reaches a chrome row must change nothing about that row: its empty
// run still drags and its controls stay clickable. Without the Desktop window styles the official rules alone
// let the control punch the row; once they stop doing so, retire the reset in src/client/styles.ts.
const checkRowAboveScroller = async (row, scroller) => {
await installDragRegions()
const result = await row.evaluate(async (element, hostSelector) => {
// While a drag row moves, the official recall pulse marks the body no-drag, and the boxes inheriting
// it cover the row. The mark also flickers off for single frames as a movement starts, so the
// surface is read in this task once the mark has stayed off for a dozen frames.
await new Promise(resolve => {
let quiet = 0
const tick = () => {
quiet = document.body.hasAttribute('data-window-drag-recall') ? 0 : quiet + 1
if (quiet >= 12) resolve()
else requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
})
const host = document.querySelector(hostSelector)
if (host === null) return { missing: hostSelector }
const box = element.getBoundingClientRect()
const points = []
for (let y = Math.max(box.top, 0) + 2; y < Math.min(box.bottom, innerHeight); y += 6) {
for (let x = Math.max(box.left, 0) + 2; x < Math.min(box.right, innerWidth); x += 12) points.push([x, y])
}
const before = globalThis.__nextDragRegions(points)
const control = document.createElement('button')
control.style.cssText = `position:fixed;left:0;top:0;width:${box.width}px;height:${box.height}px;padding:0;border:0;z-index:-1`
const content = document.createElement('span')
content.style.cssText = 'display:block;height:100%'
control.append(content)
host.append(control)
const placed = control.getBoundingClientRect()
control.style.translate = `${box.left - placed.left}px ${box.top - placed.top}px`
const own = document.querySelector('style[data-plugin$="/window-controls"]')
own.disabled = true
const official = globalThis.__nextDragRegions(points)
own.disabled = false
const corrected = globalThis.__nextDragRegions(points)
control.remove()
const differs = regions => regions.filter((region, index) => region !== before[index]).length
return { drags: before.filter(region => region === 'drag').length, punched: differs(official), changed: differs(corrected) }
}, scroller)
assert.ok(result.drags > 0, `The row above ${scroller} declares no drag: ${JSON.stringify(result)}`)
assert.ok(result.punched > 0, `The official rules no longer let a control in ${scroller} punch the row: ${JSON.stringify(result)}`)
assert.equal(result.changed, 0, `A control in ${scroller} changes the drag surface of the row above it: ${JSON.stringify(result)}`)
}
const expand = async () => {
await reopen.waitFor({ state: 'visible' })
assert.equal(await reopen.evaluate(element => getComputedStyle(element).getPropertyValue('-webkit-app-region')), 'no-drag')
Expand All @@ -267,10 +336,24 @@ try {
assert.equal(await conversationHeader.count(), 1)
assert.equal(await page.locator('[data-conversation-header-leading]').count(), 1)
await checkDrag()
for (const index of [0, 1]) await checkRowAboveScroller(page.locator('[data-window-drag]:not(header)').nth(index), '[role="tree"]')
await collapse.click()
await reopen.waitFor({ state: 'visible' })
assert.equal(await page.locator('[data-sidebar-header-controls] button').count(), 2,
'Conversation headers keep both official sidebar and new-session controls')
await checkRowAboveScroller(conversationHeader, '[data-conversation-scroll]')
// Only a surface that can escape the transcript keeps its subtraction.
assert.deepEqual(await page.evaluate(() => {
const plain = document.createElement('button')
const menu = document.createElement('div')
menu.setAttribute('role', 'menu')
const item = menu.appendChild(document.createElement('button'))
document.querySelector('[data-conversation-scroll]').append(plain, menu)
const regions = [plain, menu, item].map(element => getComputedStyle(element).getPropertyValue('-webkit-app-region'))
plain.remove()
menu.remove()
return regions
}), ['none', 'no-drag', 'no-drag'])
mkdirSync(screenshots, { recursive: true })
await page.screenshot({ path: join(screenshots, 'new-session-collapsed.png'), animations: 'disabled' })
// A tray/shortcut request activates the official Settings trigger even with the sidebar collapsed.
Expand Down Expand Up @@ -581,11 +664,22 @@ try {
assert.ok(Math.abs((await pluginHeader.boundingBox()).y - (pinnedHeader ? beforeScroll.y : beforeScroll.y - scrollTop)) < 1,
'The official plugin header keeps its original scrolling behavior')
await checkPluginHeader()
await checkRowAboveScroller(pluginHeader, '[data-plugin-body]')
// The collapsed head paints only its top part, so only that part drags.
const headerTrim = await pluginHeader.evaluate(header => ({
region: getComputedStyle(header, '::after').getPropertyValue('-webkit-app-region'),
top: getComputedStyle(header, '::after').top,
paint: getComputedStyle(header.closest('[data-plugin-bar]')).getPropertyValue('--plugin-header-paint-height'),
}))
assert.deepEqual(headerTrim, { region: 'no-drag', top: headerTrim.paint, paint: headerTrim.paint })
// Controls remain interactive when scrolled to the top of the window.
const remoteGearY = (await remoteGear.boundingBox()).y
await pluginScroller.evaluate((element, delta) => { element.scrollTop += delta }, remoteGearY - 16)
assert.ok((await remoteGear.boundingBox()).y < 52)
assert.equal(await remoteGear.evaluate(element => getComputedStyle(element).getPropertyValue('-webkit-app-region')), 'no-drag')
await remoteGear.scrollIntoViewIfNeeded()
const remoteGearBox = await remoteGear.boundingBox()
assert.notDeepEqual(await page.evaluate(points => globalThis.__nextDragRegions(points),
[[remoteGearBox.x + remoteGearBox.width / 2, remoteGearBox.y + remoteGearBox.height / 2]]), ['drag'])
await remoteGear.click()
await remoteDialog.waitFor()
await remoteDialog.getByRole('button', { name: /^(关闭远程控制|Close Remote Control|关闭手机连接)$/ }).click()
Expand Down Expand Up @@ -668,6 +762,11 @@ try {
await expand()
await settleLayout(width, false)
}
await page.getByRole('button', { name: /^(打开右侧边栏|Open right sidebar)$/ }).click()
const paneStrip = page.locator('[data-dockkit-strip][data-window-drag]')
await paneStrip.waitFor()
await checkRowAboveScroller(paneStrip, '[data-dockkit-content][data-dockkit-pane] > :not(:first-child) > *')
await page.getByRole('button', { name: /^(收起右侧边栏|Collapse right sidebar)$/ }).click()
await verifySidebarBrowser(page, nativeBrowser, screenshots)

// Other platforms retain their own native chrome; no macOS drag region leaks through.
Expand Down Expand Up @@ -961,7 +1060,7 @@ try {
await webContext.close()
assert.deepEqual(errors, [])
assert.deepEqual(await page.evaluate(() => globalThis.__NEXT_TEST_BOOT__.failures), [])
console.log(`Next window controls passed through the official ${upstreamVersion} Desktop boot branch: stacked sidebar extension entries, homepage/plugin collapse and reopen, navigation, caption geometry, clickable actions, existing-header and platform isolation, official Settings header shortcuts and keyboard navigation, grouped Desktop Settings and immediate saves, per-address login URL rows with exact open/copy targets, Profile cards and tray creation, the Host-independent recovery artifact, and native Browser toolbar, navigation, pane geometry, overlay isolation, tab lifetime and Web iframe fallback. Chromium simulates the preload contract; native Electron window movement and page loading are not tested here.`)
console.log(`Next window controls passed through the official ${upstreamVersion} Desktop boot branch: stacked sidebar extension entries, homepage/plugin collapse and reopen, navigation, caption geometry, drag rows above scrolled content, clickable actions, existing-header and platform isolation, official Settings header shortcuts and keyboard navigation, grouped Desktop Settings and immediate saves, per-address login URL rows with exact open/copy targets, Profile cards and tray creation, the Host-independent recovery artifact, and native Browser toolbar, navigation, pane geometry, overlay isolation, tab lifetime and Web iframe fallback. Chromium simulates the preload contract; native Electron window movement and page loading are not tested here.`)
console.log(`Screenshots: ${screenshots}`)
} catch (error) {
console.error(error)
Expand Down
34 changes: 33 additions & 1 deletion dsh-desktop-next/src/client/styles.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/** Next-owned surfaces; window dragging belongs to the official frontend. */
/** Next-owned surfaces, plus corrections to the official frontend's macOS window drag. */
const STYLES = `
.dshNextSafeModeNotice{position:absolute;right:16px;bottom:16px;max-width:300px;padding:12px;border:1px solid var(--dsw-alias-border-l3);border-radius:10px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font:12px/1.5 system-ui,sans-serif;pointer-events:auto;-webkit-app-region:no-drag}
.dshNextSafeModeNotice p{margin:5px 0 8px}.dshNextSafeModeNotice button{font:inherit;border:1px solid currentColor;border-radius:5px;padding:4px 8px;background:transparent;color:inherit;cursor:pointer}
Expand Down Expand Up @@ -28,6 +28,38 @@ body [data-shortcut-modal='settings'] > nav > :last-child {
overscroll-behavior: contain;
scrollbar-gutter: stable;
}

/* Chromium collects app-region boxes without clipping them, in document order, and the last box
containing a point decides it. A control scrolled beneath a chrome row therefore subtracts its
whole box from the row's drag, which the official frontend leaves unguarded. The scrollers below
chrome rows (the transcript, the Plugins page, the workspace list and dock pane bodies) never paint
their content over a drag row, so that content declares no region; the reset on each root also stops
a region inherited from a pane body. Surfaces that can escape a scroller and the rows pinned inside
one keep theirs. The control list is the official interactive no-drag list, whose html-prefixed
specificity a scoped rule can only override with !important. */
@scope (html[data-platform='darwin'] :is([data-conversation-scroll], [data-plugin-body], [role='tree'], :is([data-dockkit-content], [data-dockkit-pane]) > :not(:first-child) > *))
to (dialog, [popover], [role='dialog'], [role='alertdialog'], [role='menu'], [role='listbox'], [role='tooltip'], [data-window-drag], [data-plugin-bar]) {
:scope,
:is(
button, a, input, select, textarea, summary, [contenteditable='true'], [tabindex],
[role='dialog'], [role='alertdialog'], [role='menu'], [role='listbox'], [role='tooltip'],
[role='button'], [role='link'], [role='tab'], [role='menuitem'], [role='menuitemcheckbox'],
[role='menuitemradio'], [role='option'], [role='checkbox'], [role='radio'], [role='switch'],
[role='slider'], [role='combobox'], [role='textbox']
) {
-webkit-app-region: initial !important;
}
}

/* The collapsed Plugins head keeps its resting box but paints only its top part, and the content
below that part is visible, so only the painted part drags. */
html[data-platform='darwin'] [data-plugin-bar][data-natural] [data-window-drag]::after {
content: '';
position: absolute;
inset: var(--plugin-header-paint-height) 0 0;
pointer-events: none;
-webkit-app-region: no-drag;
}
`

export function installWindowStyles(): () => void {
Expand Down
51 changes: 51 additions & 0 deletions dsh-desktop-next/tests/window-styles.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { readdirSync, readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { installWindowStyles } from '../src/client/styles.ts'

const require = createRequire(import.meta.url)

afterEach(() => { vi.unstubAllGlobals() })

/** Install the Desktop window styles against a stub document and return the sheet's text. */
function installedStyles(): string {
let text = ''
vi.stubGlobal('document', {
createElement: () => ({ dataset: {}, textContent: '', remove: () => {} }),
head: { append: (node: { textContent: string }) => { text = node.textContent } },
})
installWindowStyles()()
return text
}

/** The official Web frontend's built assets with one extension, joined. */
function frontendAssets(extension: string): string {
const assets = join(dirname(require.resolve('@deepseek-ai/dsh-web-frontend/dist/index.html')), 'assets')
return readdirSync(assets).filter(name => name.endsWith(extension))
.map(name => readFileSync(join(assets, name), 'utf8')).join('\n')
}

/** Selector list entries without quotes or whitespace, sorted. */
function selectorList(list: string): string[] {
return list.split(',').map(entry => entry.replace(/['"\s]/gu, '')).filter(entry => entry !== '').sort()
}

it('resets every control the official frontend subtracts from macOS drag rows', () => {
const official = /html\[data-platform=darwin\] :is\(([^)]+)\)\{-webkit-app-region:no-drag\}/u.exec(frontendAssets('.css'))?.[1]
expect(official, 'the official interactive no-drag rule').toBeDefined()
const reset = /:scope,\s*:is\(([^)]+)\)/u.exec(installedStyles())?.[1]
expect(reset, 'the scroller content reset').toBeDefined()
expect(selectorList(reset!)).toEqual(selectorList(official!))
})

it('targets the scrollers and rows the official client renders', () => {
const client = (name: string) => readFileSync(require.resolve(`@deepseek-ai/${name}/client`), 'utf8')
expect(client('dsh-client-ui-conversation')).toContain('"data-conversation-scroll": ""')
expect(client('dsh-client-ui-workspace')).toContain('role: "tree"')
const plugins = client('dsh-client-ui-plugin-manager')
for (const hook of ['"data-plugin-body": true', '"data-plugin-bar": true', '"data-natural"', '"--plugin-header-paint-height"']) expect(plugins).toContain(hook)
// The dock layout ships inside the frontend bundle, not as its own client package.
const frontend = frontendAssets('.js')
for (const hook of ['"data-dockkit-content":', '"data-dockkit-pane":']) expect(frontend).toContain(hook)
})
Loading
Loading