fix(schedule): return focus when a cleanup removes its own trigger
This commit is contained in:
1 parent
422aa724e3
commit
facfab5ce8
11 files changed
+117
-28
No files matched your search
@@ -1328,10 +1328,11 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
} finally {
|
||||
// The shared page and Host outlive a failed assertion: remove every row
|
||||
// this scenario introduced so the next one starts from the same state.
|
||||
for (const id of [kept.id, ...endedIds]) {
|
||||
await scaffold.ctx.schedule.delete({ sessionId: endedSession, id })
|
||||
}
|
||||
// this scenario introduced so the next one starts from the same state. A
|
||||
// teardown rejection must not mask the assertion that failed first.
|
||||
await Promise.allSettled(
|
||||
[kept.id, ...endedIds].map(id => scaffold.ctx.schedule.delete({ sessionId: endedSession, id })),
|
||||
)
|
||||
}
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 405b30feaf48c087
|
||||
/deepseek-ai-dsh-client-ui-schedule/use-this-package:
|
||||
en: 954dc495cfcce34d
|
||||
zh: 9d861537037ff82c
|
||||
en: c1556a0d3a5ea43b
|
||||
zh: 3553ae81a0ef7410
|
||||
/deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source:
|
||||
en: 6bd6882dbbbefcb0
|
||||
zh: 03aa15f1a65d13dc
|
||||
|
||||
@@ -41,7 +41,7 @@ After the last saved page loads, a nonempty history with confirmed pruning shows
|
||||
|
||||
A successful empty history page is distinct from loading, query failure, a missing task, or an invalid cursor. Failed requests retain previously loaded records with a warning and Retry delivery records; an invalid cursor offers Refresh delivery records to reload the newest page. Changing the latest `messageId` refreshes the newest page without switching tabs. Responses superseded by a refresh or received after changing tasks, leaving Records, or closing details do not replace the current view.
|
||||
|
||||
The Automation tasks page requires confirmation before deleting a task. Deletion is hard: the Host removes the stored task row together with its saved delivery records, so the task stops future delivery, leaves `list` and `catalog`, and its delivery history is no longer readable. Deletion leaves the original Session and queued messages intact, and the list keeps the row visible only until an authoritative query confirms the removal. Every deletion path — the Tasks page detail, the Session task tab, the header popover, and the Tasks page's bulk cleanup — reports its settled outcome into one store that the app-wide `shell.overlay` notice renders, so the outcome outlives the panel or tab that asked for it: the notice reads `Task deleted.` once the Host removed the row with its saved delivery records, and `Could not delete the task.` after a failure, which leaves the rule in place to retry. The same notice reports a bulk cleanup: `Cleared N ended tasks.` with the count the Host answered, `No ended tasks to clear.` when the Host reports that it removed none, and `Could not clear the ended tasks.` after a failure that leaves the remaining ended rows in place to retry. The bulk cleanup closes only its confirmation, whereas confirming a single deletion closes the detail or the tab it was confirmed from. Each notice is keyed by its own sequence so a later one restarts the banner instead of being folded into an earlier one. A query failure keeps the last known records and shows a separate Retry action; it never appears as an empty successful result. A one-shot remains stored as inactive after durable inbox delivery. Its details and original Session id remain available through the All or Inactive filter until explicit deletion. The latest delivery shows its occurrence time; the message id and delivery acknowledgment time are not shown. Neither inactive status nor that receipt means model execution completed.
|
||||
The Automation tasks page requires confirmation before deleting a task. Deletion is hard: the Host removes the stored task row together with its saved delivery records, so the task stops future delivery, leaves `list` and `catalog`, and its delivery history is no longer readable. Deletion leaves the original Session and queued messages intact, and the list keeps the row visible only until an authoritative query confirms the removal. Every deletion path — the Tasks page detail, the Session task tab, the header popover, and the Tasks page's bulk cleanup — reports its settled outcome into one store that the app-wide `shell.overlay` notice renders, so the outcome outlives the panel or tab that asked for it: the notice reads `Task deleted.` once the Host removed the row with its saved delivery records, and `Could not delete the task.` after a failure, which leaves the rule in place to retry. The same notice reports a bulk cleanup: `Cleared N ended tasks.` with the count the Host answered, `No ended tasks to clear.` when the Host reports that it removed none, and `Could not clear every ended task.` after a failure that leaves the remaining ended rows in place to retry. The bulk cleanup closes only its confirmation, whereas confirming a single deletion closes the detail or the tab it was confirmed from. Each notice is keyed by its own sequence so a later one restarts the banner instead of being folded into an earlier one. A query failure keeps the last known records and shows a separate Retry action; it never appears as an empty successful result. A one-shot remains stored as inactive after durable inbox delivery. Its details and original Session id remain available through the All or Inactive filter until explicit deletion. The latest delivery shows its occurrence time; the message id and delivery acknowledgment time are not shown. Neither inactive status nor that receipt means model execution completed.
|
||||
|
||||
An open Session renders an icon-only clock button among its header's right-aligned utilities, immediately left of the overflow menu, only once it has something to open: the first read mounts nothing, a read that returns no active reminder leaves the header without the button, a refresh keeps the last known reminders visible, and a failed read keeps the button with its retry. That rule prevents the clock from appearing and disappearing around the first answer. Its accessible name states the reminder count when the read is ready and the generic reminder label otherwise. With exactly one active reminder, the button opens that task's detail in the right Sidebar directly. With any other count it opens a popover that lists overdue reminders first, then future reminders by target time, with stored title, frequency, browser-local target time, relative time, and a Delete button; the title is a button that opens that task's detail in the right Sidebar. The task tab names the shown task's stored title on its chip and carries the same rule, delivery records, name, instruction, and run-time editing, confirmed deletion, and original-Session link as the Automation tasks page; it never activates a Session. Daily frequency text retains the saved local time and zone, and every next-target instant is shown as a device-zone clock with its relative duration in parentheses; the rule's own zone appears only in that frequency text. Escape closes the popover and returns focus to the button; an outside pointer press dismisses it. Deleting the final reminder removes the button, and an already open popover stays expandable until then.
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ kind: "package-reference"
|
||||
|
||||
历史查询成功的空页与加载中、查询失败、任务不存在或游标无效相互区分。请求失败时保留此前加载的记录,并显示警告和“重试加载任务运行记录”;游标无效时提供“刷新任务运行记录”,重新加载最新一页。最新 `messageId` 变化时刷新最新一页,不切换页签。被刷新取代的响应,以及切换任务、离开任务运行记录或关闭详情后才收到的响应,不会替换当前视图。
|
||||
|
||||
自动化任务页面要求确认后才能删除任务。删除是硬删除:Host 连同已保存的任务运行记录一起移除存储的任务行,该任务因此停止后续投递、离开 `list` 与 `catalog`,其投递历史不再可读。删除保留原会话和已入队消息;列表仅在权威查询确认移除之前保留该行。每条删除路径——自动化任务页的详情、会话的任务页签、以及页头弹层——都把最终结果写入同一个存储,由应用级 `shell.overlay` 提示渲染,因此提示的生命周期长于发起它的面板或页签:Host 连同已保存的运行记录移除该行后提示显示「任务已删除」,失败时显示「无法删除任务」,并保留规则以便重试。批量清理也由该提示报告:显示 Host 答复的行数「已清理 N 个已结束任务」;Host 答复未移除任何行时显示「没有可清理的已结束任务」;失败时显示「无法清理已结束任务」,并保留尚未移除的未运行行以便重试。批量清理只关闭自己的确认对话框。确认删除会关闭确认所在的面板或页签,每条提示按自身序号区分,后一条会重新开始横幅而不是并入前一条。查询失败保留最近已知记录,并显示独立的重试操作;不会呈现为查询成功的空结果。一次性任务在持久收件箱投递后仍以未运行状态保存。显式删除前,通过全部或未运行筛选仍可查看详情与原会话 id。最近一次投递显示其发生时间,不显示消息 id 和投递确认时间;未运行状态和该回执均不表示模型执行完成。
|
||||
自动化任务页面要求确认后才能删除任务。删除是硬删除:Host 连同已保存的任务运行记录一起移除存储的任务行,该任务因此停止后续投递、离开 `list` 与 `catalog`,其投递历史不再可读。删除保留原会话和已入队消息;列表仅在权威查询确认移除之前保留该行。每条删除路径——自动化任务页的详情、会话的任务页签、以及页头弹层——都把最终结果写入同一个存储,由应用级 `shell.overlay` 提示渲染,因此提示的生命周期长于发起它的面板或页签:Host 连同已保存的运行记录移除该行后提示显示「任务已删除」,失败时显示「无法删除任务」,并保留规则以便重试。批量清理也由该提示报告:显示 Host 答复的行数「已清理 N 个已结束任务」;Host 答复未移除任何行时显示「没有可清理的已结束任务」;失败时显示「无法清理全部已结束任务」,并保留尚未移除的未运行行以便重试。批量清理只关闭自己的确认对话框。确认删除会关闭确认所在的面板或页签,每条提示按自身序号区分,后一条会重新开始横幅而不是并入前一条。查询失败保留最近已知记录,并显示独立的重试操作;不会呈现为查询成功的空结果。一次性任务在持久收件箱投递后仍以未运行状态保存。显式删除前,通过全部或未运行筛选仍可查看详情与原会话 id。最近一次投递显示其发生时间,不显示消息 id 和投递确认时间;未运行状态和该回执均不表示模型执行完成。
|
||||
|
||||
已打开的会话只有在确实有可打开的内容时,才在页头右侧功能位中、紧邻更多操作菜单左侧渲染一个仅图标时钟按钮:首次读取前不渲染任何内容,读取结果为没有活动提醒时页头不显示该按钮,刷新期间保留上一次已知的提醒,读取失败则保留按钮及其重试。该规则避免时钟在首次结果前后出现又消失。其无障碍名称在读取就绪时给出提醒数量,否则显示通用提醒标签。当该会话只有一个活动提醒时,按钮直接在右侧栏打开该任务的详情。其余数量都会打开弹层:先列出逾期提醒,再按目标时间列出未来提醒,显示已存储的标题、频率、浏览器本地目标时间、相对时间及删除按钮;标题本身是一个按钮,点击后在右侧栏打开该任务的详情。任务页签在标签上显示当前任务已存储的标题,并提供与自动化任务页面相同的规则、任务运行记录、名称、指令和运行时间修改、确认删除和原会话入口;它不会激活会话。每日频率文本保留保存的本地时间和时区;每个下一目标时点都显示为设备时区的钟点和括号内的相对时长;规则自身的时区只显示在该频率文本中。Escape 关闭弹层并将焦点返回按钮;点击外部将其关闭。删除最后一条提醒会移除按钮;在此之前,已经打开的弹层保持可展开状态。
|
||||
|
||||
|
||||
@@ -98,16 +98,17 @@ export function ScheduleDeleteToast({ useToast, dismiss, t }: ScheduleDeleteToas
|
||||
case 'deleteFailed':
|
||||
return <Toast key={key} text={t('toast.deleteFailed')} icon={<IconWarningOutlineRegular />} onDone={dismiss} />
|
||||
case 'cleared':
|
||||
// A removal that found nothing states that instead of a zero count: the
|
||||
// ended rows can leave between the confirmation and the request.
|
||||
return <Toast
|
||||
key={key}
|
||||
text={toast.count === 0
|
||||
? t('toast.clearedNone')
|
||||
: t(toast.count === 1 ? 'toast.cleared.one' : 'toast.cleared.other', { count: toast.count })}
|
||||
tone="success"
|
||||
onDone={dismiss}
|
||||
/>
|
||||
// A removal that found nothing states that instead of a zero count, and
|
||||
// without the success tone: the ended rows can leave between the
|
||||
// confirmation and the request.
|
||||
return toast.count === 0
|
||||
? <Toast key={key} text={t('toast.clearedNone')} onDone={dismiss} />
|
||||
: <Toast
|
||||
key={key}
|
||||
text={t(toast.count === 1 ? 'toast.cleared.one' : 'toast.cleared.other', { count: toast.count })}
|
||||
tone="success"
|
||||
onDone={dismiss}
|
||||
/>
|
||||
case 'clearFailed':
|
||||
return <Toast key={key} text={t('toast.clearFailed')} icon={<IconWarningOutlineRegular />} onDone={dismiss} />
|
||||
/* v8 ignore next -- the store publishes only the union's members, so the default holds no reachable statement. */
|
||||
|
||||
@@ -66,6 +66,10 @@ export function TaskManagerPage(props: TaskManagerPageProps) {
|
||||
const { record: catalogRecord, task: selected, id: detailId, confirmId, setConfirmId, setTab } = detail
|
||||
const rowRef = useRef<HTMLButtonElement | null>(null)
|
||||
const headingRef = useRef<HTMLHeadingElement>(null)
|
||||
const clearTriggerRef = useRef<HTMLButtonElement | null>(null)
|
||||
// Set when the confirmation opens: its settled removal can hide the trigger
|
||||
// before the Modal returns focus, and so can another client's cleanup.
|
||||
const clearFocusPending = useRef(false)
|
||||
const confirming = records.find(record => record.id === confirmId)
|
||||
// The bulk action's target and count come from the complete catalog, not from
|
||||
// the filtered rows: it removes every ended task, whatever the view shows.
|
||||
@@ -115,6 +119,16 @@ export function TaskManagerPage(props: TaskManagerPageProps) {
|
||||
setSelectedId(null)
|
||||
}
|
||||
|
||||
// The Modal returns focus to the control that opened it only while that
|
||||
// control still exists; a cleanup that removes every ended row unmounts the
|
||||
// trigger before the confirmation closes, so the page heading takes the focus.
|
||||
useEffect(() => {
|
||||
if (clearConfirm || !clearFocusPending.current) return
|
||||
clearFocusPending.current = false
|
||||
if (clearTriggerRef.current?.isConnected === true) return
|
||||
headingRef.current?.focus()
|
||||
}, [clearConfirm])
|
||||
|
||||
const confirmClearEnded = (): void => {
|
||||
setClearing(true)
|
||||
void onClearEnded().finally(() => {
|
||||
@@ -129,9 +143,11 @@ export function TaskManagerPage(props: TaskManagerPageProps) {
|
||||
aria-label={t('title')}
|
||||
data-testid="task-manager-page"
|
||||
onKeyDown={(event) => {
|
||||
// Escape belongs to the surface it closed: the Menu primitive consumes it
|
||||
// during the document capture phase, and the Modal layer consumes it while
|
||||
// a confirmation is open. This handler must not also close the detail.
|
||||
// An Escape that closed a dropdown is already consumed by the Menu's
|
||||
// document capture listener. A confirmation's own Escape reaches the
|
||||
// Modal's document bubble listener, which runs after this React handler,
|
||||
// so the `clearConfirm` guard is what keeps this page from also closing
|
||||
// the detail while the dialog closes.
|
||||
if (event.key !== 'Escape' || event.defaultPrevented || confirmId !== null || clearConfirm
|
||||
|| selectedId === null) return
|
||||
event.preventDefault()
|
||||
@@ -146,10 +162,14 @@ export function TaskManagerPage(props: TaskManagerPageProps) {
|
||||
<h1 ref={headingRef} tabIndex={-1}>{t('title')}</h1>
|
||||
<div className={css.creationActions}>
|
||||
{endedCount > 0 && <Button
|
||||
ref={clearTriggerRef}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={css.headerAction}
|
||||
onClick={() => { setClearConfirm(true) }}
|
||||
onClick={() => {
|
||||
clearFocusPending.current = true
|
||||
setClearConfirm(true)
|
||||
}}
|
||||
>{t('clear.action')}</Button>}
|
||||
<Button variant="primary" size="sm" className={css.headerAction} icon={<IconPlusOutlineRegular size={13} />} onClick={onNewTask}>{t('new.action')}</Button>
|
||||
</div>
|
||||
|
||||
@@ -24,8 +24,8 @@ export interface ClearEndedDependencies {
|
||||
*
|
||||
* The call names no task ids: the Host owns which rows are ended, and a
|
||||
* delivery can have moved the catalog between the confirmation and the
|
||||
* request. The readback follows the acknowledged removal, so the list the page
|
||||
* renders next agrees with the count the notice states.
|
||||
* request. The readback follows the acknowledged removal and supersedes a read
|
||||
* issued before it, so the list follows the removal rather than a stale read.
|
||||
* @param deps - Remote removal and its catalog readback.
|
||||
* @returns a callback that resolves once the settled outcome is available.
|
||||
*/
|
||||
|
||||
@@ -148,7 +148,7 @@ export const en = {
|
||||
'toast.cleared.one': 'Cleared {count} ended task.',
|
||||
'toast.cleared.other': 'Cleared {count} ended tasks.',
|
||||
'toast.clearedNone': 'No ended tasks to clear.',
|
||||
'toast.clearFailed': 'Could not clear the ended tasks.',
|
||||
'toast.clearFailed': 'Could not clear every ended task.',
|
||||
'card.open': 'Open',
|
||||
'card.openLabel': 'Open task details: {title}',
|
||||
'card.deleted': 'Deleted',
|
||||
@@ -302,7 +302,7 @@ export const zh: Record<TaskManagerKey, string> = {
|
||||
'toast.cleared.one': '已清理 {count} 个已结束任务',
|
||||
'toast.cleared.other': '已清理 {count} 个已结束任务',
|
||||
'toast.clearedNone': '没有可清理的已结束任务',
|
||||
'toast.clearFailed': '无法清理已结束任务',
|
||||
'toast.clearFailed': '无法清理全部已结束任务',
|
||||
'card.open': '打开',
|
||||
'card.openLabel': '打开任务详情:{title}',
|
||||
'card.deleted': '已删除',
|
||||
|
||||
@@ -11,7 +11,9 @@ import { apply as applyNode } from '../src/index.ts'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
|
||||
import type { ScheduleId, ScheduleCatalogEntry, ScheduleUpdateRequest, ScheduleUpdateResult } from '@deepseek-ai/dsh-schedule/client'
|
||||
import type {
|
||||
ScheduleId, ScheduleCatalogEntry, ScheduleDeleteInactiveResult, ScheduleUpdateRequest, ScheduleUpdateResult,
|
||||
} from '@deepseek-ai/dsh-schedule/client'
|
||||
import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { CatalogInjected } from '../src/client/catalog-source.ts'
|
||||
@@ -519,6 +521,39 @@ describe('ui-schedule browser half', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('reports a settled bulk removal after the page registration is gone', async () => {
|
||||
const catalog = vi.fn().mockResolvedValue({ ok: true, value: [] })
|
||||
const removal = Promise.withResolvers<{ ok: true; value: ScheduleDeleteInactiveResult }>()
|
||||
const deleteInactive = vi.fn(() => removal.promise)
|
||||
const ctx = await baseContext({ schedule: { catalog, deleteInactive }, $on: () => () => {} })
|
||||
const owner = ctx.slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
main: { kind: 'keyed', scope: 'root' },
|
||||
'shell.overlay': { kind: 'list', scope: 'root' },
|
||||
},
|
||||
} as never, Empty)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
let claimed = false
|
||||
try {
|
||||
const face = injectedFace(ctx.slots.entries('main')[0]!, undefined as never) as TaskManagerInjected
|
||||
const toast = injectedFace(ctx.slots.entries('shell.overlay')[0]!) as Omit<DeleteToastSource, 'report' | 'reportClear'>
|
||||
const clearing = face.onClearEnded()
|
||||
// The page panel can unmount while the Host removal is still in flight;
|
||||
// the notice store belongs to the plugin, so the outcome still publishes.
|
||||
owner()
|
||||
claimed = true
|
||||
removal.resolve({ ok: true, value: { deleted: 2 } })
|
||||
await clearing
|
||||
expect(toast.hooks.toast.getSnapshot()).toEqual({ kind: 'cleared', count: 2, seq: 1 })
|
||||
} finally {
|
||||
if (!claimed) owner()
|
||||
await fiber.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('registers the row mark and hover-card seats under the Session row declarations', async () => {
|
||||
const catalog = vi.fn().mockResolvedValue({ ok: true, value: [] })
|
||||
const ctx = await baseContext({ schedule: { catalog }, $on: () => () => {} })
|
||||
|
||||
@@ -1015,6 +1015,37 @@ describe('clearing ended tasks', () => {
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
})
|
||||
|
||||
it('falls back to the page heading when the settled cleanup hides its trigger', async () => {
|
||||
const pending = Promise.withResolvers<undefined>()
|
||||
const onClearEnded = vi.fn(() => pending.promise)
|
||||
const h = mount({ records: [at, endedTask] }, en, { onClearEnded })
|
||||
const trigger = screen.getByRole('button', { name: en['clear.action'] })
|
||||
trigger.focus()
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(screen.getByRole('button', { name: en['clear.confirm'] }))
|
||||
// The acknowledged removal's readback drops the ended rows, and with them the
|
||||
// trigger the Modal captured when it opened.
|
||||
act(() => { h.update({ records: [at] }) })
|
||||
expect(screen.queryByRole('button', { name: en['clear.action'] })).toBeNull()
|
||||
await act(async () => { pending.resolve(undefined) })
|
||||
expect(screen.queryByRole('dialog', { name: en['clear.title'] })).toBeNull()
|
||||
expect(document.activeElement).toBe(screen.getByRole('heading', { level: 1 }))
|
||||
})
|
||||
|
||||
it('falls back to the page heading when another cleanup removes the trigger', () => {
|
||||
const h = mount({ records: [at, endedTask] })
|
||||
const trigger = screen.getByRole('button', { name: en['clear.action'] })
|
||||
trigger.focus()
|
||||
fireEvent.click(trigger)
|
||||
// Another client's cleanup removes the ended rows while this confirmation
|
||||
// is open, so the control the Modal captured is unmounted too.
|
||||
act(() => { h.update({ records: [at] }) })
|
||||
expect(screen.queryByRole('button', { name: en['clear.action'] })).toBeNull()
|
||||
fireEvent.keyDown(screen.getByRole('dialog', { name: en['clear.title'] }), { key: 'Escape' })
|
||||
expect(screen.queryByRole('dialog', { name: en['clear.title'] })).toBeNull()
|
||||
expect(document.activeElement).toBe(screen.getByRole('heading', { level: 1 }))
|
||||
})
|
||||
|
||||
it('closes only the confirmation on Escape while a task detail is open', () => {
|
||||
mount({ records: [at, endedTask] })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Review release' }))
|
||||
|
||||
@@ -388,7 +388,8 @@ export class ScheduleService extends TypertRemoteService {
|
||||
let failure: Error | undefined
|
||||
for (const id of ended) {
|
||||
try {
|
||||
// A row another queued write already removed is not this call's removal.
|
||||
// Only a row this call removed is counted: the domain answers false
|
||||
// when the row is already absent.
|
||||
if (await tasks.delete(id)) deleted += 1
|
||||
} catch (error: unknown) {
|
||||
// Every row is attempted, so one failed delete does not leave the rest
|
||||
|
||||
Reference in new issue
Block a user