三件事一起落地(用户 brief),各自独立可验证:
1) 后台任务输出改为常驻浮动窗
- 新增 src/client/FloatingWindow.tsx + .module.css:可复用组件,标题栏(拖拽手柄 +
复制/关闭)、内容区(自滚动,长输出不再跑出视口)、可选底部行、三个拉伸柄
(右/下/右下)。生命周期与 AnchoredPopover 相反:外部点击、失焦、锚点离屏都不关,
只有关闭按钮与 Escape 结束;尺寸只在挂载时量一次(流式输出帧不会挪动读者摆好的窗);
拖拽保持整窗在视口内(放不下才退化到保留 48px 可达);视口变化时夹回。
- 手势复用既有范式:移动走 window 级 pointermove(同 AnchoredPopover),拉伸走
setPointerCapture + createFrameBatcher(同 EditorHost 的面板拉伸)。
- JobOutputPopoverContent → JobOutputWindow;顺带修掉「跟随最新」从不滚动的 bug
(原先滚的是 TerminalBlock 外面那个永不滚动的 div),并清掉随旧弹窗死掉的
.popPre / .jobPopPre 规则。节点/工作流详情与任务窗保持原样。
2) 节点卡统一
- 删除两条左侧竖条(层级条 .cardTop::before、当前会话条 .nodeCurrent::before);
层级改由上段极淡底色承担(7/5/3.5/2.5% → 9/7/5/3.5% 补偿对比度);
当前会话改为 2px accent 描边(「边框加粗高亮」)。
- 圆角 4px → 8px(与浮动窗同一家族);成员/工作流/聚合按用户选择保留类型区分。
- 2px 而非 1.5px:真机 recon 发现 Chromium 在 DPR 1 下把亚像素边框向下取整,
1.5px 的「加粗」完全看不出(实测 borderTopWidth: "1px")。几何不受影响
(border-box + min-height,卡片外框 247×134 与预算逐像素一致)。
3) 手机设置
- 两个新偏好 mobileNoAutoOpen / mobileDefaultTree(默认都开),落到 prefs-shared、
PrefsSchema(volatile 自动)、parsePrefs 校验与设置页新增的「手机」分组。
- 窄屏(沿用 768px 断点)时:新子代理与新后台任务都不再自动激活任务页;
任务页默认树状图(页内切换仍是临时覆盖)。宽屏或开关关掉后行为完全不变。
- 5 条词条 ×20 份词典;外部插件指南的 PrefsSchema 键清单同步。
验证:typecheck / lint ✅;pnpm test 1254 passed / 9 skipped(新增 14 例);
挂载 lane 7/7(真实 DSH 0.1.7-rc.2);3080 真机自测通过(长输出窗口内部滚动
5459px/449px、拖拽 424→304、拉伸 1008×540→918×480、外部点击不关、Escape 关、
卡片 8px/2px/无竖条、窄屏不自动弹出且默认树状图)。
(cherry picked from commit 7d9b428c6c34d28ad2d60c2086496eb533902273)
245 lines
9.5 KiB
TypeScript
245 lines
9.5 KiB
TypeScript
/**
|
||
* The persistent floating window (FloatingWindow.tsx): its dismissal contract
|
||
* (close button + Escape ONLY — no outside click, no blur, no anchor
|
||
* observer), its two gestures (title-bar drag, edge/corner resize with a
|
||
* floor), and the bounded scrolling body that makes a long job output
|
||
* readable.
|
||
*
|
||
* jsdom has no layout engine, so geometry is asserted through the element's
|
||
* inline box (which the component writes) and through pointer-event maths —
|
||
* the real pixels are covered by the mount lane and the 3080 self-check.
|
||
*/
|
||
// @vitest-environment jsdom
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||
import { createElement, createRef, type RefObject } from 'react'
|
||
import { act } from 'react-dom/test-utils'
|
||
import { renderRoot, setupReactAct } from './test-utils.ts'
|
||
import { FloatingWindow } from '../src/client/FloatingWindow.tsx'
|
||
|
||
setupReactAct()
|
||
|
||
/** The portaled window element. */
|
||
function frame(): HTMLElement {
|
||
const node = document.querySelector('[data-floating-window]')
|
||
if (node === null) throw new Error('no floating window rendered')
|
||
return node as HTMLElement
|
||
}
|
||
|
||
/** The inline box the component wrote (jsdom performs no layout). */
|
||
function box(): { left: number; top: number; width: number; height: number } {
|
||
const style = frame().style
|
||
return {
|
||
left: Number.parseFloat(style.left),
|
||
top: Number.parseFloat(style.top),
|
||
width: Number.parseFloat(style.width),
|
||
height: Number.parseFloat(style.height),
|
||
}
|
||
}
|
||
|
||
/** Dispatch one pointer event with coordinates. */
|
||
function pointer(target: EventTarget, type: string, x: number, y: number): void {
|
||
const event = new Event(type, { bubbles: true, cancelable: true }) as Event & {
|
||
clientX?: number; clientY?: number; button?: number; pointerId?: number
|
||
}
|
||
Object.assign(event, { clientX: x, clientY: y, button: 0, pointerId: 1 })
|
||
target.dispatchEvent(event)
|
||
}
|
||
|
||
let closed = 0
|
||
|
||
beforeEach(() => {
|
||
closed = 0
|
||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1_000 })
|
||
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 })
|
||
// The chrome's copy is localized: pin the language so the close button's
|
||
// accessible name is the zh one (the other specs do the same).
|
||
Object.defineProperty(globalThis.navigator, 'language', { value: 'zh-CN', configurable: true })
|
||
})
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals()
|
||
for (const el of document.querySelectorAll('body > div')) el.remove()
|
||
})
|
||
|
||
/** Render one window (plus an optional anchor) and return its controls. */
|
||
function renderWindow(props: {
|
||
anchor?: HTMLElement | null
|
||
bodyRef?: RefObject<HTMLDivElement>
|
||
footer?: boolean
|
||
actions?: boolean
|
||
} = {}) {
|
||
const rendered = renderRoot(createElement(FloatingWindow, {
|
||
title: 'bash-1',
|
||
onClose: () => { closed += 1 },
|
||
...(props.anchor === undefined ? {} : { anchor: props.anchor }),
|
||
...(props.bodyRef === undefined ? {} : { bodyRef: props.bodyRef }),
|
||
...(props.footer === true ? { footer: createElement('button', { type: 'button' }, 'stop') } : {}),
|
||
...(props.actions === true ? { actions: createElement('button', { type: 'button' }, 'copy') } : {}),
|
||
children: createElement('div', null, 'line 1'),
|
||
}))
|
||
return rendered
|
||
}
|
||
|
||
describe('FloatingWindow frame', () => {
|
||
it('renders the title, the actions, the footer and a scrollable body', () => {
|
||
const bodyRef = createRef<HTMLDivElement>()
|
||
const { unmount } = renderWindow({ actions: true, footer: true, bodyRef })
|
||
expect(frame().getAttribute('role')).toBe('dialog')
|
||
expect(frame().getAttribute('aria-label')).toBe('bash-1')
|
||
expect(frame().textContent).toContain('bash-1')
|
||
expect(frame().querySelector('[data-window-handle]')).not.toBeNull()
|
||
expect(frame().querySelector('[data-window-body]')).not.toBeNull()
|
||
expect(frame().querySelector('[data-window-footer]')).not.toBeNull()
|
||
// The caller's follow-tail hook receives the real scroll container.
|
||
expect(bodyRef.current).toBe(frame().querySelector('[data-window-body]'))
|
||
unmount()
|
||
})
|
||
|
||
it('sizes itself from the viewport and centres without an anchor', () => {
|
||
const { unmount } = renderWindow()
|
||
const current = box()
|
||
// 70% × 60% of 1000 × 800.
|
||
expect(current.width).toBe(700)
|
||
expect(current.height).toBe(480)
|
||
expect(current.left).toBe(150)
|
||
unmount()
|
||
})
|
||
|
||
it('opens next to its anchor, clamped into the viewport', () => {
|
||
const anchor = document.createElement('button')
|
||
document.body.append(anchor)
|
||
anchor.getBoundingClientRect = () => ({
|
||
left: 940, top: 700, right: 990, bottom: 720, width: 50, height: 20,
|
||
x: 940, y: 700, toJSON: () => ({}),
|
||
}) as DOMRect
|
||
const { unmount } = renderWindow({ anchor })
|
||
const current = box()
|
||
// The right edge would overflow: the window is pulled back inside.
|
||
expect(current.left + current.width).toBeLessThanOrEqual(1_000 - 8 + 1)
|
||
expect(current.top).toBeGreaterThan(0)
|
||
expect(current.top).toBeLessThan(800)
|
||
unmount()
|
||
})
|
||
})
|
||
|
||
describe('FloatingWindow dismissal', () => {
|
||
it('closes on the close button and on Escape', () => {
|
||
const { unmount } = renderWindow()
|
||
const close = frame().querySelector('button[aria-label="关闭"]') as HTMLButtonElement
|
||
expect(close).not.toBeNull()
|
||
act(() => { close.click() })
|
||
expect(closed).toBe(1)
|
||
act(() => {
|
||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||
})
|
||
expect(closed).toBe(2)
|
||
unmount()
|
||
})
|
||
|
||
it('is PERSISTENT: an outside click, a window blur and an anchor observer never close it', () => {
|
||
const observed: Element[] = []
|
||
vi.stubGlobal('IntersectionObserver', class {
|
||
observe(target: Element): void { observed.push(target) }
|
||
disconnect(): void {}
|
||
})
|
||
const anchor = document.createElement('button')
|
||
document.body.append(anchor)
|
||
const { unmount } = renderWindow({ anchor })
|
||
// An outside mousedown: the popover contract would close here.
|
||
act(() => {
|
||
document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||
})
|
||
act(() => { window.dispatchEvent(new Event('blur')) })
|
||
act(() => { document.dispatchEvent(new Event('visibilitychange')) })
|
||
expect(closed).toBe(0)
|
||
// No anchor observer is even registered (the window outlives its opener).
|
||
expect(observed).toEqual([])
|
||
expect(document.querySelector('[data-floating-window]')).not.toBeNull()
|
||
unmount()
|
||
})
|
||
})
|
||
|
||
describe('FloatingWindow gestures', () => {
|
||
it('drags by the title bar and keeps the whole window on screen', () => {
|
||
const { unmount } = renderWindow()
|
||
const handle = frame().querySelector('[data-window-handle]') as HTMLElement
|
||
const before = box()
|
||
act(() => {
|
||
pointer(handle, 'pointerdown', 500, 100)
|
||
pointer(window, 'pointermove', 420, 160)
|
||
pointer(window, 'pointerup', 420, 160)
|
||
})
|
||
const moved = box()
|
||
expect(moved.left).toBe(before.left - 80)
|
||
expect(moved.top).toBe(before.top + 60)
|
||
expect(moved.width).toBe(before.width)
|
||
// A wild drag cannot hide the window (nor its resize corner): it parks in
|
||
// the viewport's corner instead of hanging off an edge.
|
||
act(() => {
|
||
pointer(handle, 'pointerdown', 420, 160)
|
||
pointer(window, 'pointermove', -5_000, -5_000)
|
||
pointer(window, 'pointerup', -5_000, -5_000)
|
||
})
|
||
expect(box().top).toBe(8)
|
||
expect(box().left).toBe(8)
|
||
// …and the far corner of the box stays inside the viewport.
|
||
expect(box().left + box().width).toBeLessThanOrEqual(1_000 - 8)
|
||
expect(box().top + box().height).toBeLessThanOrEqual(800 - 8)
|
||
unmount()
|
||
})
|
||
|
||
it('resizes from the corner and never shrinks below the floor', () => {
|
||
const { unmount } = renderWindow()
|
||
const corner = frame().querySelector('[data-window-resize="both"]') as HTMLElement
|
||
const before = box()
|
||
act(() => {
|
||
pointer(corner, 'pointerdown', 0, 0)
|
||
pointer(corner, 'pointermove', 60, 40)
|
||
pointer(corner, 'pointerup', 60, 40)
|
||
})
|
||
// The per-frame batcher may hold the last move; a flush lands it (the
|
||
// release path does exactly this).
|
||
expect(box().width).toBeGreaterThanOrEqual(before.width)
|
||
expect(box().height).toBeGreaterThanOrEqual(before.height)
|
||
// Dragging far past the floor clamps to the minimum size.
|
||
act(() => {
|
||
pointer(corner, 'pointerdown', 0, 0)
|
||
pointer(corner, 'pointermove', -5_000, -5_000)
|
||
pointer(corner, 'pointerup', -5_000, -5_000)
|
||
})
|
||
expect(box().width).toBe(320)
|
||
expect(box().height).toBe(240)
|
||
unmount()
|
||
})
|
||
|
||
it('resizes one axis at a time from the edge handles', () => {
|
||
const { unmount } = renderWindow()
|
||
const right = frame().querySelector('[data-window-resize="x"]') as HTMLElement
|
||
const before = box()
|
||
act(() => {
|
||
pointer(right, 'pointerdown', 0, 0)
|
||
pointer(right, 'pointermove', -100, 90)
|
||
pointer(right, 'pointerup', -100, 90)
|
||
})
|
||
const after = box()
|
||
expect(after.width).toBeLessThan(before.width)
|
||
// The vertical edge handle ignores horizontal travel entirely.
|
||
expect(after.height).toBe(before.height)
|
||
unmount()
|
||
})
|
||
|
||
it('does not start a drag from a header control', () => {
|
||
const { unmount } = renderWindow({ actions: true })
|
||
const before = box()
|
||
const copy = [...frame().querySelectorAll('button')].find(b => b.textContent === 'copy') as HTMLButtonElement
|
||
act(() => {
|
||
pointer(copy, 'pointerdown', 500, 100)
|
||
pointer(window, 'pointermove', 300, 300)
|
||
pointer(window, 'pointerup', 300, 300)
|
||
})
|
||
expect(box().left).toBe(before.left)
|
||
expect(box().top).toBe(before.top)
|
||
unmount()
|
||
})
|
||
})
|