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:
Menghuan1918
2026-09-27 22:18:57 +08:00
parent 5ea2f2d6ec
commit d38fefe55d
38 changed files with 1300 additions and 156 deletions
+244
View File
@@ -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()
})
})
+19
View File
@@ -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)
+29 -9
View File
@@ -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')
+40 -6
View File
@@ -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')
+63 -6
View File
@@ -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()
})
})