feat(tasks): 任务输出常驻浮动窗 + 节点卡去竖线 + 手机设置
三件事一起落地(用户 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)
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* 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()
|
||||
})
|
||||
})
|
||||
@@ -35,6 +35,8 @@ describe('side card preferences', () => {
|
||||
autoOpenSubagent: false,
|
||||
autoOpenJobs: true,
|
||||
tasksViewMode: 'graph',
|
||||
mobileNoAutoOpen: true,
|
||||
mobileDefaultTree: true,
|
||||
agentOpenTools: true,
|
||||
editorExplorer: false,
|
||||
workspaceFence: true,
|
||||
@@ -57,6 +59,8 @@ describe('side card preferences', () => {
|
||||
autoOpenSubagent: true,
|
||||
autoOpenJobs: true,
|
||||
tasksViewMode: 'graph',
|
||||
mobileNoAutoOpen: true,
|
||||
mobileDefaultTree: true,
|
||||
agentOpenTools: false,
|
||||
editorExplorer: false,
|
||||
workspaceFence: true,
|
||||
@@ -79,6 +83,8 @@ describe('side card preferences', () => {
|
||||
autoOpenSubagent: true,
|
||||
autoOpenJobs: true,
|
||||
tasksViewMode: 'graph',
|
||||
mobileNoAutoOpen: true,
|
||||
mobileDefaultTree: true,
|
||||
agentOpenTools: false,
|
||||
editorExplorer: false,
|
||||
workspaceFence: true,
|
||||
@@ -109,6 +115,19 @@ describe('side card preferences', () => {
|
||||
.toBe(false)
|
||||
})
|
||||
|
||||
it('defaults both mobile adaptations to on; only an explicit false disarms one', async () => {
|
||||
// Absent or malformed → ON: a phone is exactly the case these exist for,
|
||||
// and each one only changes what happens on a NARROW viewport.
|
||||
for (const key of ['mobileNoAutoOpen', 'mobileDefaultTree'] as const) {
|
||||
expect((await loadPrefs(wire({})))[key]).toBe(true)
|
||||
expect((await loadPrefs(wire({ [key]: 'yes' })))[key]).toBe(true)
|
||||
expect((await loadPrefs(wire({ [key]: 1 })))[key]).toBe(true)
|
||||
// Explicit booleans survive verbatim.
|
||||
expect((await loadPrefs(wire({ [key]: false })))[key]).toBe(false)
|
||||
expect((await loadPrefs(wire({ [key]: true })))[key]).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('defaults editorExplorer to false; only an explicit true enables the merged editor-explorer', async () => {
|
||||
// Absent or malformed → off (separate file windows are the default).
|
||||
expect((await loadPrefs(wire({}))).editorExplorer).toBe(false)
|
||||
|
||||
@@ -92,12 +92,15 @@ describe('SideCardSection declarative inventory', () => {
|
||||
expect(html).toContain('>Subagents<')
|
||||
// Default prefs: the general switch is off (agentOpenTools defaults off),
|
||||
// and both tabs + the image viewer cards are pressed (3 aria-pressed
|
||||
// cards).
|
||||
// cards). The mobile switches are NOT cards — they are rows in the 手机
|
||||
// group, and their own defaults (both on) show up as checked checkboxes.
|
||||
// The nested auto-open toggle is NOT an inline card (it lives in the popup).
|
||||
expect(pressedCount(html, 'true')).toBe(3)
|
||||
expect(pressedCount(html, 'false')).toBe(0)
|
||||
// The general toggles are custom switches (real checkboxes, none checked).
|
||||
expect(html.match(/checked=""/g)?.length ?? 0).toBe(0)
|
||||
// The custom switches are real checkboxes: the two mobile adaptations are
|
||||
// on by default, agentOpenTools is not.
|
||||
// The two mobile adaptations are on by default.
|
||||
expect(html.match(/checked=""/g)?.length ?? 0).toBe(2)
|
||||
expect(html).not.toContain('Auto-open Subagents')
|
||||
})
|
||||
|
||||
@@ -112,6 +115,20 @@ describe('SideCardSection declarative inventory', () => {
|
||||
expect(html).toContain('>1</span>')
|
||||
})
|
||||
|
||||
it('renders the mobile group: heading + both narrow-viewport switches, on by default', () => {
|
||||
const { store, service } = mount()
|
||||
const html = renderSection(store, service)
|
||||
expect(html).toContain('>Mobile</div>')
|
||||
expect(html).toContain('auto-open on narrow screens')
|
||||
expect(html).toContain('Default the Tasks page to the tree')
|
||||
// Both are ON by default; disarming one shows up as an unchecked box.
|
||||
expect(html).toContain('checked=""')
|
||||
store.setPrefs({ ...store.getPrefs(), mobileNoAutoOpen: false, mobileDefaultTree: false })
|
||||
const off = renderSection(store, service)
|
||||
expect(off).toContain('>Mobile</div>')
|
||||
expect(off).not.toContain('checked=""')
|
||||
})
|
||||
|
||||
it('renders one small card per registered viewer: icon + title + exts', () => {
|
||||
const { store, service } = mount()
|
||||
const html = renderSection(store, service)
|
||||
@@ -153,9 +170,10 @@ describe('SideCardSection declarative inventory', () => {
|
||||
expect(html).toContain('>Subagents<')
|
||||
expect(html).toContain('>Image<')
|
||||
expect(pressedCount(html, 'false')).toBe(2)
|
||||
// The explorer card stays pressed; no general switch is on by default.
|
||||
// The explorer card stays pressed.
|
||||
expect(pressedCount(html, 'true')).toBe(1)
|
||||
expect(html.match(/checked=""/g)?.length ?? 0).toBe(0)
|
||||
// The two mobile adaptations are on by default.
|
||||
expect(html.match(/checked=""/g)?.length ?? 0).toBe(2)
|
||||
})
|
||||
|
||||
it('hides the gear of a disabled feature (its related settings are dormant)', () => {
|
||||
@@ -178,10 +196,12 @@ describe('SideCardSection declarative inventory', () => {
|
||||
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<')
|
||||
// One general-row switch remains (agentOpenTools), off by default — the
|
||||
// scheme row is a dropdown, not a switch.
|
||||
expect(html.match(/type="checkbox"/g)?.length).toBe(1)
|
||||
expect(html.match(/checked=""/g)?.length ?? 0).toBe(0)
|
||||
// Three general-row switches remain (agentOpenTools off by default, plus
|
||||
// the two mobile adaptations on) — the scheme row is a dropdown, not a
|
||||
// switch.
|
||||
expect(html.match(/type="checkbox"/g)?.length).toBe(3)
|
||||
// The two mobile adaptations are on by default (agentOpenTools is not).
|
||||
expect(html.match(/checked=""/g)?.length ?? 0).toBe(2)
|
||||
// Auto (default) needs no further settings → no gear.
|
||||
expect(html).not.toContain('Position compatibility mode Feature settings')
|
||||
|
||||
|
||||
@@ -228,7 +228,7 @@ function setViewport(width: number): void {
|
||||
function mountSidebar(
|
||||
width: number,
|
||||
bottomOpen = false,
|
||||
options: { columnExpanded?: boolean } = {},
|
||||
options: { columnExpanded?: boolean; prefs?: Record<string, unknown> } = {},
|
||||
): MountedSidebar {
|
||||
setViewport(width)
|
||||
vi.stubGlobal('WebSocket', FakeWebSocket)
|
||||
@@ -243,7 +243,12 @@ function mountSidebar(
|
||||
// (`ctx.jobs`): the feed below is what a test pushes rosters into.
|
||||
const jobs = makeJobsFeed()
|
||||
const store = createSidebarStore()
|
||||
store.setPrefs({ ...store.getPrefs(), autoOpenSubagent: true, autoOpenJobs: true })
|
||||
store.setPrefs({
|
||||
...store.getPrefs(),
|
||||
autoOpenSubagent: true,
|
||||
autoOpenJobs: true,
|
||||
...options.prefs,
|
||||
})
|
||||
store.setSession(sessionId)
|
||||
store.reduce(state => ({ ...state, bottomOpen }))
|
||||
const service = createBetterSidebarService(store)
|
||||
@@ -408,7 +413,10 @@ describe('Sidebar background-activity auto-activation (#162)', () => {
|
||||
{ source: 'subagent', width: 1024 },
|
||||
{ source: 'job', width: 1024 },
|
||||
] as const)('$source activation at $width px activates the Tasks page in the native Sidebar', async ({ source, width }) => {
|
||||
const sidebar = mountSidebar(width)
|
||||
// The NARROW rows disarm the mobile adaptation on purpose: this lane pins
|
||||
// the activate-and-park promise, while the suppression the mobile switch
|
||||
// adds on narrow viewports has its own test below.
|
||||
const sidebar = mountSidebar(width, false, { prefs: { mobileNoAutoOpen: false } })
|
||||
await publishActivity(sidebar, source)
|
||||
expectNativeTasksOpen(sidebar, sidebar.sessionId)
|
||||
expectWorkbenchUntouched(sidebar)
|
||||
@@ -417,13 +425,13 @@ describe('Sidebar background-activity auto-activation (#162)', () => {
|
||||
})
|
||||
|
||||
it.each(['subagent', 'job'] as const)('%s activation leaves a narrow fullscreen column to the park', async (source) => {
|
||||
const sidebar = mountSidebar(390)
|
||||
const sidebar = mountSidebar(390, false, { prefs: { mobileNoAutoOpen: false } })
|
||||
await publishActivity(sidebar, source)
|
||||
expect(sidebar.column.toggles).toBe(1)
|
||||
})
|
||||
|
||||
it('a narrow column the user already expanded is not closed under them', async () => {
|
||||
const sidebar = mountSidebar(390, false, { columnExpanded: true })
|
||||
const sidebar = mountSidebar(390, false, { columnExpanded: true, prefs: { mobileNoAutoOpen: false } })
|
||||
await publishActivity(sidebar, 'subagent')
|
||||
expectNativeTasksOpen(sidebar, sidebar.sessionId)
|
||||
expect(sidebar.column.toggles).toBe(0)
|
||||
@@ -471,7 +479,7 @@ describe('Sidebar background-activity auto-activation (#162)', () => {
|
||||
})
|
||||
|
||||
it('reads the viewport when the debounced activation fires, not when it arms', () => {
|
||||
const sidebar = mountSidebar(1024)
|
||||
const sidebar = mountSidebar(1024, false, { prefs: { mobileNoAutoOpen: false } })
|
||||
publishSubagent(sidebar)
|
||||
setViewport(390)
|
||||
flushSubagentDebounce()
|
||||
@@ -479,6 +487,32 @@ describe('Sidebar background-activity auto-activation (#162)', () => {
|
||||
expect(sidebar.column.toggles).toBe(1)
|
||||
})
|
||||
|
||||
it('a NARROW viewport suppresses both triggers while the mobile switch is on', async () => {
|
||||
// The mobile adaptation defaults to ON: on a phone the Tasks page costs
|
||||
// the whole screen, so neither kind of background work may take it over.
|
||||
const subagents = mountSidebar(390)
|
||||
await publishActivity(subagents, 'subagent')
|
||||
expect(subagents.surface.opens).toEqual([])
|
||||
await publishActivity(subagents, 'job')
|
||||
expect(subagents.surface.opens).toEqual([])
|
||||
|
||||
// Turning the switch off restores the old narrow behaviour: activate, then
|
||||
// park the fullscreen column (the existing narrow promise).
|
||||
const jobs = mountSidebar(390)
|
||||
jobs.store.setPrefs({ ...jobs.store.getPrefs(), mobileNoAutoOpen: false })
|
||||
await publishActivity(jobs, 'job')
|
||||
expectNativeTasksOpen(jobs, jobs.sessionId)
|
||||
expect(jobs.column.toggles).toBe(1)
|
||||
})
|
||||
|
||||
it('the mobile switch never touches a WIDE viewport', async () => {
|
||||
const sidebar = mountSidebar(1024)
|
||||
await publishActivity(sidebar, 'subagent')
|
||||
expectNativeTasksOpen(sidebar, sidebar.sessionId)
|
||||
// Nothing was parked (there is no fullscreen column to park).
|
||||
expect(sidebar.column.toggles).toBe(0)
|
||||
})
|
||||
|
||||
it('leaves an already-open bottom workbench open and untouched', async () => {
|
||||
const sidebar = mountSidebar(1024, true)
|
||||
await publishActivity(sidebar, 'subagent')
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createElement } from 'react'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { renderRoot } from './test-utils.ts'
|
||||
import { SubagentView } from '../src/client/SubagentView.tsx'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
import type {
|
||||
Context,
|
||||
SidebarClientJobsService,
|
||||
@@ -232,6 +233,46 @@ describe('Tasks page interactions', () => {
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('opens in the TREE by default on a narrow viewport when the mobile switch is on', async () => {
|
||||
// jsdom's window is the plugin's mobile bracket here (767 < 768).
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 420 })
|
||||
try {
|
||||
const store = makeStore(snapshotWithChildren(2))
|
||||
const sidebar = createSidebarStore()
|
||||
sidebar.setPrefs({ ...sidebar.getPrefs(), tasksViewMode: 'graph' })
|
||||
const first = renderRoot(
|
||||
createElement(SubagentView, {
|
||||
sessionId: 'root', active: true, ctx: makeCtx(store), store: sidebar,
|
||||
}),
|
||||
)
|
||||
await flushJobs()
|
||||
// The mobile adaptation wins over the wide-viewport default…
|
||||
expect(first.container.querySelector('[role="tree"]')).not.toBeNull()
|
||||
expect(first.container.querySelector('[role="group"]')).toBeNull()
|
||||
// …but the in-page toggle still switches this session's page ad hoc.
|
||||
const toGraph = first.container.querySelector('button[aria-label="切换为工作流图"]') as HTMLButtonElement
|
||||
await act(async () => { toGraph.click() })
|
||||
expect(first.container.querySelector('[role="group"]')).not.toBeNull()
|
||||
first.unmount()
|
||||
|
||||
// Disarming the switch restores the settings default (graph) on the same
|
||||
// narrow viewport.
|
||||
const off = makeStore(snapshotWithChildren(2))
|
||||
const offSidebar = createSidebarStore()
|
||||
offSidebar.setPrefs({ ...offSidebar.getPrefs(), tasksViewMode: 'graph', mobileDefaultTree: false })
|
||||
const second = renderRoot(
|
||||
createElement(SubagentView, {
|
||||
sessionId: 'root', active: true, ctx: makeCtx(off), store: offSidebar,
|
||||
}),
|
||||
)
|
||||
await flushJobs()
|
||||
expect(second.container.querySelector('[role="group"]')).not.toBeNull()
|
||||
second.unmount()
|
||||
} finally {
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1024 })
|
||||
}
|
||||
})
|
||||
|
||||
it('toggles between graph and tree with the cluster visible in BOTH modes', async () => {
|
||||
const store = makeStore(snapshotWithChildren(2))
|
||||
const { container, unmount } = renderRoot(
|
||||
@@ -478,7 +519,7 @@ describe('Tasks page: owned tasks, host-primitive controls, draggable output', (
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('drags the job output popover away from its anchor', async () => {
|
||||
it('opens the job output in the persistent floating window and drags it by its title bar', async () => {
|
||||
const store = makeStore(snapshotWithChildren(1))
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
@@ -486,15 +527,31 @@ describe('Tasks page: owned tasks, host-primitive controls, draggable output', (
|
||||
await flushJobs()
|
||||
const row = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
|
||||
await act(async () => { row.click() })
|
||||
const card = document.querySelector('[role="dialog"]') as HTMLElement
|
||||
expect(card).not.toBeNull()
|
||||
const before = { left: card.style.left, top: card.style.top }
|
||||
// The job output is a WINDOW, not the 280px anchored popover: it carries
|
||||
// the plugin's frame (a title bar to drag, a scrolling body).
|
||||
const frame = document.querySelector('[data-floating-window]') as HTMLElement
|
||||
expect(frame).not.toBeNull()
|
||||
expect(frame.getAttribute('aria-label')).toBe('sleep 300')
|
||||
expect(frame.querySelector('[data-window-body]')).not.toBeNull()
|
||||
// An outside click must NOT dismiss it (the window is persistent); Escape does.
|
||||
await act(async () => {
|
||||
card.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 40, clientY: 40 }))
|
||||
document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||
})
|
||||
expect(document.querySelector('[data-floating-window]')).not.toBeNull()
|
||||
|
||||
const before = { left: frame.style.left, top: frame.style.top }
|
||||
const handle = frame.querySelector('[data-window-handle]') as HTMLElement
|
||||
await act(async () => {
|
||||
handle.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 40, clientY: 40 }))
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 140, clientY: 120 }))
|
||||
window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
|
||||
})
|
||||
expect({ left: card.style.left, top: card.style.top }).not.toEqual(before)
|
||||
expect({ left: frame.style.left, top: frame.style.top }).not.toEqual(before)
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
})
|
||||
expect(document.querySelector('[data-floating-window]')).toBeNull()
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user