Merge pull request #3379 from deepseek-harness/schedule-catalog-left-alignment

fix(web): align Schedule catalog within viewport
This commit is contained in:
imccyu
2026-08-31 20:53:40 +08:00
committed by GitHub
12 changed files with 110 additions and 30 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md
2026-08-05-durable-web-schedule.md: 2a07d8257df6e940b316749a9b96a13abaf201dc
2026-08-05-durable-web-schedule.zh.md: c3a37f69ab74e5ededb7ca89c45ad9acfd029251
2026-08-05-durable-web-schedule.md: bac4a5cfd8965032dad2cf689ca42b1f8e5da1e3
2026-08-05-durable-web-schedule.zh.md: 266a90d3a56caccda56be9fe648c059939b8155f
@@ -60,7 +60,7 @@ Dispatch records queue admission, not model completion or user receipt. Framing
### Read-only Web catalog
The Schedule overlay enables the otherwise-disabled [`dsh-client-ui-schedule`](../../../../packages/client/ui-schedule/README.md) client together with the Host service. The complete active projection also feeds [`dsh-client-ui-workspace`](../../../../packages/client/ui-workspace/README.md). This note owns that opt-in read-only presentation boundary: the projection is current active state, not a dispatch or delivery receipt, so ordinary Assistant turns remain the delivery presentation.
The Schedule overlay enables the otherwise-disabled [`dsh-client-ui-schedule`](../../../../packages/client/ui-schedule/README.md) client together with the Host service. The complete active projection also feeds [`dsh-client-ui-workspace`](../../../../packages/client/ui-workspace/README.md). This note owns that opt-in read-only presentation boundary: the projection is current active state, not a dispatch or delivery receipt, so ordinary Assistant turns remain the delivery presentation. The catalog is a fixed `document.body` portal whose left edge follows the trigger when space permits and shifts left to retain a 16px viewport margin near the right edge. `useAnchoredPosition` owns measurement and resize, captured-scroll, panel-resize, and cleanup behavior; Schedule supplies the trigger and portal refs, bottom placement, a 5px gap, and the existing inside/outside dismissal boundary without adding a general popover abstraction.
## Alternatives considered
@@ -80,7 +80,7 @@ The Schedule overlay enables the otherwise-disabled [`dsh-client-ui-schedule`](.
## Verification
Package tests pin strict replay, one-shot and Every transitions, creation-anchor arithmetic, latest-only catch-up, multi-record batching, fork suffixes, id reuse, offset and local-calendar profiles, IANA validation, daylight-saving gaps and overlaps, time bounds, timer segmentation, wall-clock movement, overdue admission, fixed framing, enqueue and append failures, barrier recovery, projection registration and restoration, registration rollback, and quiescent disposal at per-file 100% coverage. A property test compares Every calculation and replay across varied intervals and skipped spans. A production JSONL restart test proves one overdue reminder dispatches through the real Agent lifecycle and does not redispatch after another restart. Focused client suites own catalog and sidebar behavior. Keyless assembled Web scenarios retain ordinary After/At/Every delivery evidence plus one Schedule-catalog smoke for overlay reachability, the current header catalog, ordinary/search alarms, narrow dark layout, and one live empty update.
Package tests pin strict replay, one-shot and Every transitions, creation-anchor arithmetic, latest-only catch-up, multi-record batching, fork suffixes, id reuse, offset and local-calendar profiles, IANA validation, daylight-saving gaps and overlaps, time bounds, timer segmentation, wall-clock movement, overdue admission, fixed framing, enqueue and append failures, barrier recovery, projection registration and restoration, registration rollback, and quiescent disposal at per-file 100% coverage. A property test compares Every calculation and replay across varied intervals and skipped spans. A production JSONL restart test proves one overdue reminder dispatches through the real Agent lifecycle and does not redispatch after another restart. Focused client suites own catalog and sidebar behavior, including the body portal, spacious left alignment, portal-inside pointer handling, outside dismissal, Escape, live empty, and timer cleanup. The shared primitive suite owns the positioning hook's resize, captured-scroll, panel-resize, and cleanup lifecycle. Keyless assembled Web scenarios retain ordinary After/At/Every delivery evidence plus one 900×900 Schedule-catalog smoke for overlay reachability, fixed portal placement, right-edge clamping, width and overflow, ordinary/search alarms, narrow dark layout, a light-theme browser screenshot, and one live empty update.
## Consequences
@@ -60,7 +60,7 @@ dispatch 记录的是队列准入,而不是模型完成或用户收到提醒
### 只读 Web 目录
Schedule overlay 会把默认禁用的 [`dsh-client-ui-schedule`](../../../../packages/client/ui-schedule/README.zh.md) client 与 Host 服务一同启用。完整活动 projection 也会交给 [`dsh-client-ui-workspace`](../../../../packages/client/ui-workspace/README.zh.md)。本 Note 拥有这条 opt-in 只读呈现边界:该 projection 表示当前活动状态,而非 dispatch 或交付回执,因此普通 Assistant 轮次仍是交付呈现。
Schedule overlay 会把默认禁用的 [`dsh-client-ui-schedule`](../../../../packages/client/ui-schedule/README.zh.md) client 与 Host 服务一同启用。完整活动 projection 也会交给 [`dsh-client-ui-workspace`](../../../../packages/client/ui-workspace/README.zh.md)。本 Note 拥有这条 opt-in 只读呈现边界:该 projection 表示当前活动状态,而非 dispatch 或交付回执,因此普通 Assistant 轮次仍是交付呈现。目录是挂到 `document.body` 的 fixed portal;空间足够时左边缘跟随触发按钮,靠近视口右侧时向左避让并保留 16px 边距。`useAnchoredPosition` 拥有测量以及 resize、捕获阶段 scroll、面板 resize 与清理行为;Schedule 提供触发器与 portal ref、bottom 放置、5px 间距和既有内外 dismissal 边界,不增加通用 popover 抽象。
## 已考虑的替代方案
@@ -80,7 +80,7 @@ Schedule overlay 会把默认禁用的 [`dsh-client-ui-schedule`](../../../../pa
## 验证
包测试以逐文件 100% coverage 固定严格回放、一次性与 Every 状态转换、创建锚点运算、只追赶最新一次、多记录批处理、fork 后缀、id 复用、偏移量与本地日历 profile、IANA 校验、夏令时缺口与重叠、时间边界、timer 分段、墙钟变化、overdue 准入、固定 framing、入队与 append 失败、barrier 恢复、projection 注册与恢复、注册 rollback 和完全停稳的 dispose。属性测试会在不同间隔与跳过跨度下比较 Every 计算与回放。production JSONL restart 测试证明一条 overdue 提醒会经过真实 Agent 生命周期 dispatch,并且再次 restart 后不会重复 dispatch。聚焦 client suite 拥有目录与侧边栏行为。无密钥组装 Web 场景保留普通 After/AtEvery 交付证据,再由一个 Schedule 目录 smoke 覆盖 overlay 可达性、当前 header 目录、普通/搜索闹钟、窄屏暗色布局与一次 live empty 更新。
包测试以逐文件 100% coverage 固定严格回放、一次性与 Every 状态转换、创建锚点运算、只追赶最新一次、多记录批处理、fork 后缀、id 复用、偏移量与本地日历 profile、IANA 校验、夏令时缺口与重叠、时间边界、timer 分段、墙钟变化、overdue 准入、固定 framing、入队与 append 失败、barrier 恢复、projection 注册与恢复、注册 rollback 和完全停稳的 dispose。属性测试会在不同间隔与跳过跨度下比较 Every 计算与回放。production JSONL restart 测试证明一条 overdue 提醒会经过真实 Agent 生命周期 dispatch,并且再次 restart 后不会重复 dispatch。聚焦 client suite 拥有目录与侧边栏行为,包括 body portal、空间充足时的左对齐、portal 内指针处理、外部 dismissal、Escape、live empty 与 timer 清理。共享 primitive suite 拥有定位 hook 的 resize、捕获阶段 scroll、面板 resize 与清理生命周期。无密钥组装 Web 场景保留普通 After/At/Every 交付证据,再由一个 900×900 Schedule 目录 smoke 覆盖 overlay 可达性、fixed portal 定位、右侧钳制、宽度与 overflow、普通/搜索闹钟、窄屏暗色布局、浅色主题浏览器截图与一次 live empty 更新。
## 后果
+36 -8
View File
@@ -1,6 +1,6 @@
/** Keyless assembled-Web evidence for conversational Schedule delivery. */
import { readFile } from 'node:fs/promises'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
@@ -29,6 +29,7 @@ import {
type WebScaffold,
} from './scaffold.ts'
import {
REPO_ROOT,
connectFreshWorkspace,
conversationContextKey,
saveFailureShot,
@@ -708,18 +709,34 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
const catalog = page.getByRole('list', { name: 'Active reminders' })
await catalog.waitFor({ timeout: 10_000 })
expect(await catalog.getByRole('listitem').count()).toBe(3)
const lightLayout = await catalog.evaluate((element) => {
const box = element.getBoundingClientRect()
const lightLayout = await page.evaluate(() => {
const triggerElement = document.querySelector('button[aria-label="3 reminders"]')
const catalogElement = document.querySelector('[aria-label="Active reminders"]')
if (!(triggerElement instanceof HTMLElement) || !(catalogElement instanceof HTMLElement)) {
throw new Error('active reminder trigger or catalog is not mounted')
}
const triggerBox = triggerElement.getBoundingClientRect()
const catalogBox = catalogElement.getBoundingClientRect()
const viewport = window.innerWidth
return {
width: box.width,
right: box.right,
viewport: window.innerWidth,
bodyPortal: catalogElement.parentElement === document.body,
position: getComputedStyle(catalogElement).position,
triggerLeft: triggerBox.left,
catalogLeft: catalogBox.left,
catalogRight: catalogBox.right,
width: catalogBox.width,
viewport,
expectedLeft: Math.min(Math.max(16, triggerBox.left), viewport - catalogBox.width - 16),
scrollWidth: document.documentElement.scrollWidth,
background: getComputedStyle(element).backgroundColor,
background: getComputedStyle(catalogElement).backgroundColor,
}
})
expect(lightLayout.bodyPortal).toBe(true)
expect(lightLayout.position).toBe('fixed')
expect(lightLayout.width).toBe(336)
expect(lightLayout.right).toBeLessThanOrEqual(lightLayout.viewport)
expect(lightLayout.catalogLeft).toBe(lightLayout.expectedLeft)
expect(lightLayout.catalogLeft).toBeLessThan(lightLayout.triggerLeft)
expect(lightLayout.catalogRight).toBeLessThanOrEqual(lightLayout.viewport - 16)
expect(lightLayout.scrollWidth).toBeLessThanOrEqual(lightLayout.viewport)
expect(lightLayout.background).not.toBe('rgba(0, 0, 0, 0)')
const longRow = catalog.getByRole('listitem').filter({ hasText: 'Join release review' })
@@ -760,6 +777,17 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
}))
expect(scrollLayout.scrollHeight).toBeGreaterThan(scrollLayout.clientHeight)
const evidenceDir = join(REPO_ROOT, '.artifacts')
await mkdir(evidenceDir, { recursive: true })
await writeFile(
join(evidenceDir, 'web-e2e-schedule-catalog-left-alignment.json'),
`${JSON.stringify(lightLayout, null, 2)}\n`,
)
await page.screenshot({
path: join(evidenceDir, 'web-e2e-schedule-catalog-left-alignment.png'),
fullPage: true,
})
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
const darkBackground = await catalog.evaluate(element => getComputedStyle(element).backgroundColor)
expect(darkBackground).not.toBe('rgba(0, 0, 0, 0)')
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-schedule/README.md
README.md: 2d75746876daf453b01ff2e84b6e62ed018c205c
README.zh.md: 32925e73aed252189764c4eafc0f5ecc58bb2a08
README.md: 2792cef4ae5cc0d0ad76262d05e06c83a7d0fc54
README.zh.md: 0add76f0455ff2b2ca720444f3a0250134664853
+2 -2
View File
@@ -35,7 +35,7 @@ The shipped Web graph already resolves `@deepseek-ai/dsh-client-ui-schedule` thr
### Read and dismiss the catalog
Each row shows the complete wrapping prompt, a separate Scheduled or Overdue status, localized Once or the largest exact whole unit for a repeating interval, browser-local target time, and browser-clock-relative time. Intervals are never rounded, and the three metadata fields wrap across lines instead of clipping valid large values. The 336px popover scrolls vertically when needed and exposes no Schedule id, raw UTC value, details, or action controls.
Each row shows the complete wrapping prompt, a separate Scheduled or Overdue status, localized Once or the largest exact whole unit for a repeating interval, browser-local target time, and browser-clock-relative time. Intervals are never rounded, and the three metadata fields wrap across lines instead of clipping valid large values. The body-portaled popover targets 336px, shares the trigger's left edge when space permits, and shifts left to retain a 16px viewport margin when the trigger is near the right edge; its maximum width is the viewport width minus 32px. It scrolls vertically when needed and exposes no Schedule id, raw UTC value, details, or action controls.
Only the native trigger button enters the tab order. Enter and Space use normal button activation; while focus remains on the trigger or catalog, Escape closes the popover and restores trigger focus; an outside pointer press dismisses it. If a live update removes the final record, the component closes and unmounts without moving focus to another header action. A failed Session open hides the trigger even when a tentative cached projection exists.
@@ -47,7 +47,7 @@ Only the native trigger button enters the tab order. Enter and Space use normal
<details>
<summary>Implementation internals — click to expand</summary>
The browser plugin contributes `schedule-catalog` to `conversation.session.header.actions` at order 10, after static Agent and Subagent context and before background Jobs. It reads `openState` through the standard Session hook and the complete value through `useProjection('schedule')`; popover visibility is its only local interaction state. Browser formatting uses the viewing locale, time zone, and clock, while durable Schedule records remain unchanged.
The browser plugin contributes `schedule-catalog` to `conversation.session.header.actions` at order 10, after static Agent and Subagent context and before background Jobs. It reads `openState` through the standard Session hook and the complete value through `useProjection('schedule')`; popover visibility is its only local interaction state. The component portals the catalog to `document.body` and gives its trigger and panel refs to `useAnchoredPosition`, which publishes fixed coordinates after measuring the rendered panel, keeps a 5px gap below the trigger, clamps to a 16px viewport margin, and remeasures on resize, captured scroll, and panel resize. The catalog ref also makes pointer presses inside the portal part of the existing dismissal boundary. Browser formatting uses the viewing locale, time zone, and clock, while durable Schedule records remain unchanged.
### Source map
+2 -2
View File
@@ -35,7 +35,7 @@ dsh web --patch apps/cli/config/examples/schedule/cordis.yml
### 阅读和关闭目录
每一行显示可完整换行的 prompt、独立的「等待中」或「已逾期」状态、本地化的「单次」或重复间隔可整除的最大完整单位、浏览器本地目标时间,以及按浏览器时钟派生的相对时间。间隔绝不舍入,三项元数据会按行换行,不会裁剪合法的大数值。336px 宽的弹层在需要时纵向滚动,不显示 Schedule id、原始 UTC 值、详情或操作控件。
每一行显示可完整换行的 prompt、独立的「等待中」或「已逾期」状态、本地化的「单次」或重复间隔可整除的最大完整单位、浏览器本地目标时间,以及按浏览器时钟派生的相对时间。间隔绝不舍入,三项元数据会按行换行,不会裁剪合法的大数值。通过 portal 挂到 body 的弹层目标宽度为 336px;空间足够时与触发按钮左边缘对齐,触发器靠近视口右侧时向左避让并保留 16px 视口边距,最大宽度为视口宽度减 32px。弹层在需要时纵向滚动,不显示 Schedule id、原始 UTC 值、详情或操作控件。
只有原生触发按钮进入 Tab 顺序。Enter 与 Space 使用按钮的正常激活行为;焦点仍在触发器或目录内时,Escape 会关闭弹层并把焦点交还触发器;在外部按下指针也会关闭。若 live 更新移除最后一条记录,组件会关闭并卸载,但不会把焦点移到另一个会话头部动作。Session 打开失败时,即使存在暂定的缓存 projection,也会隐藏触发器。
@@ -47,7 +47,7 @@ dsh web --patch apps/cli/config/examples/schedule/cordis.yml
<details>
<summary>实现细节——点击展开</summary>
浏览器插件以顺序 10 向 `conversation.session.header.actions` 贡献 `schedule-catalog`,位于静态 Agent 与 Subagent 上下文之后、后台 Jobs 之前。它通过标准 Session hook 读取 `openState`,通过 `useProjection('schedule')` 读取完整值;弹层开合是它唯一的本地交互状态。浏览器格式化使用查看方的 locale、时区与时钟,持久 Schedule 记录保持不变。
浏览器插件以顺序 10 向 `conversation.session.header.actions` 贡献 `schedule-catalog`,位于静态 Agent 与 Subagent 上下文之后、后台 Jobs 之前。它通过标准 Session hook 读取 `openState`,通过 `useProjection('schedule')` 读取完整值;弹层开合是它唯一的本地交互状态。组件把目录 portal 到 `document.body`,并将触发器与面板 ref 交给 `useAnchoredPosition`;该 hook 在测量已渲染面板后发布 fixed 坐标,使面板位于触发器下方 5px、钳制在 16px 视口边距内,并在 resize、捕获阶段 scroll 与面板 resize 时重新测量。目录 ref 也让 portal 内的指针按下继续属于既有 dismissal 边界之内。浏览器格式化使用查看方的 locale、时区与时钟,持久 Schedule 记录保持不变。
### 源码地图
+1
View File
@@ -61,6 +61,7 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@testing-library/react": "^16.1.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0",
"react-dom": "^18.2.0"
@@ -39,9 +39,7 @@
}
.menu {
position: absolute;
top: calc(100% + 5px);
right: 0;
position: fixed;
z-index: 100;
box-sizing: border-box;
display: flex;
@@ -1,8 +1,12 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
import {
useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent,
} from 'react'
import { createPortal } from 'react-dom'
import type { ScheduleRecord } from '@deepseek-ai/dsh-schedule/client'
import {
IconAlarmClockOutline16,
IconChevronDownOutline14,
useAnchoredPosition,
useDismissOnOutsidePointer,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
@@ -19,6 +23,7 @@ type TimeUnit = 'day' | 'hour' | 'minute' | 'second'
const EMPTY_RECORDS: readonly ScheduleRecord[] = []
const SECOND_MS = 1_000
const SECOND_UNIT = { unit: 'second', seconds: 1 } as const
const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
const UNIT_SECONDS: readonly { unit: TimeUnit; seconds: number }[] = [
{ unit: 'day', seconds: 86_400 },
{ unit: 'hour', seconds: 3_600 },
@@ -105,8 +110,17 @@ export function ScheduleCatalogAction({ useSession, useProjection, t }: Schedule
const [now, setNow] = useState(() => Date.now())
const rootRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const catalogRef = useRef<HTMLUListElement>(null)
const catalogPosition = useAnchoredPosition({
open,
anchorRef: triggerRef,
panelRef: catalogRef,
side: 'bottom',
gap: 5,
margin: 16,
})
useDismissOnOutsidePointer(rootRef, open, setOpen)
useDismissOnOutsidePointer(rootRef, open, setOpen, catalogRef)
useEffect(() => {
if (!open) return
@@ -151,8 +165,13 @@ export function ScheduleCatalogAction({ useSession, useProjection, t }: Schedule
</button>
)
const catalog = open
? (
<ul className={css.menu} aria-label={t('list.aria')}>
? createPortal((
<ul
ref={catalogRef}
className={css.menu}
style={catalogPosition ?? MEASURE_STYLE}
aria-label={t('list.aria')}
>
{rows.map((record) => {
const overdue = Date.parse(record.scheduledAt) <= now
return (
@@ -178,7 +197,7 @@ export function ScheduleCatalogAction({ useSession, useProjection, t }: Schedule
)
})}
</ul>
)
), document.body)
: null
return (
@@ -27,6 +27,7 @@ beforeEach(() => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.useRealTimers()
})
@@ -124,6 +125,34 @@ describe('ScheduleCatalogAction visibility', () => {
})
})
describe('ScheduleCatalogAction positioning', () => {
it('portals the catalog to the body and left-aligns it when space is available', () => {
const active = [record('active', 'after', START + 60_000)]
const view = render(<ScheduleCatalogAction {...props(active)} />)
const trigger = screen.getByRole('button', { name: '1 reminder' })
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({
x: 240,
y: 20,
left: 240,
right: 320,
top: 20,
bottom: 48,
width: 80,
height: 28,
toJSON: () => ({}),
})
fireEvent.click(trigger)
const catalog = screen.getByRole('list', { name: en['list.aria'] })
expect(view.container.contains(catalog)).toBe(false)
expect(catalog.parentElement).toBe(document.body)
expect(catalog.style.left).toBe('240px')
expect(catalog.style.top).toBe('53px')
expect(catalog.style.visibility).toBe('')
})
})
describe('ScheduleCatalogAction rows', () => {
it('shows only prompt and the three derived metadata fields, with overdue records first', () => {
const rawPrompt = '<img src=x onerror=alert(1)> Keep the complete long reminder prompt visible without truncation.'
@@ -238,11 +267,13 @@ describe('ScheduleCatalogAction dismissal', () => {
expect(document.activeElement).toBe(sibling)
})
it('toggles from the trigger and dismisses only on an outside pointer press', () => {
it('keeps a pointer press inside the portaled catalog open and dismisses outside', () => {
render(<ScheduleCatalogAction {...props(active)} />)
const trigger = screen.getByRole('button')
fireEvent.click(trigger)
fireEvent.pointerDown(screen.getByRole('list', { name: en['list.aria'] }))
const catalog = screen.getByRole('list', { name: en['list.aria'] })
expect(catalog.parentElement).toBe(document.body)
fireEvent.pointerDown(catalog)
expect(trigger.getAttribute('aria-expanded')).toBe('true')
fireEvent.pointerDown(document.body)
expect(trigger.getAttribute('aria-expanded')).toBe('false')
+3
View File
@@ -3086,6 +3086,9 @@ importers:
'@types/react':
specifier: ~18.3.1
version: 18.3.31
'@types/react-dom':
specifier: ~18.3.0
version: 18.3.7(@types/react@18.3.31)
react:
specifier: ^18.2.0
version: 18.3.1