Merge pull request #7925 from deepseek-harness/turtle/fix-windows-cursor-schedule-07061c1d6f38

fix(ui-schedule): keep Pause/Resume enabled-looking while the Host answers
This commit is contained in:
winewill authored and GitHub committed 2026-11-06 14:37:22 +08:00
commit e69ab4fcc5
7 files changed
+105 -26

No files matched your search

+2 -2
View File
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 405b30feaf48c087
/deepseek-ai-dsh-client-ui-schedule/use-this-package:
en: 99626c29eb801b6f
zh: 9693d278f467ad90
en: 68526e7df25338d4
zh: 3bfdfa01dc9afaec
/deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source:
en: 4005b800e930f581
zh: ccb9318dad7f7e8f
+1 -1
View File
@@ -35,7 +35,7 @@ The destination menu offers a new conversation that the first delivery creates a
The repeat editor offers once, minute, hour, day, week, month, and custom rules. Date and clock controls reuse existing pickers. Guided presets produce RRULE; minute and hour intervals start from the current minute in the selected zone. The Custom choice and Customize input do not name either syntax; the input accepts a two-line RRULE or a five-field Cron expression with an explicit zone. RRULE retains the zone in `DTSTART`; Cron retains its expression and selected zone as Cron timing. Complex RRULE values remain verbatim. The shared time parser checks new or changed Custom text before enabling Save; invalid or empty input keeps the draft and explains which field to change. Invalid Custom text offers manual correction, another repeat option, or correction by the Agent. The Host performs the final validation on save. Stored After and At tasks show date and clock fields immediately; a repeated local time uses UTC when needed to retain the exact instant. Every, Daily, and Weekly tasks show the matching controls when their interval or wall-clock precision is representable. Other stored timings remain in Custom and keep their typed timing until replaced. Viewing a task or editing other fields preserves its timing and committed occurrence.
Pause and resume update lifecycle state. Unsaved changes hold pause, resume, Run now, and Delete until you save or discard them. The detail menu offers Run now and Delete. It offers no queued-message cancellation: one inbox message may contain deliveries from several tasks. While a delivery to a reused conversation is queued or running, the detail disables its fields, Save, pause and resume, Run now, and Delete, and states that the task is queued or running and is managed in its conversation, which the bottom conversation entry opens. The minimum delivery interval disables another immediate run until the interval ends, while pause and deletion remain available once execution is idle. When the interval is the only cause, the Run now row stays focusable, and its tooltip states that the minimum run interval has not passed and when Run now becomes available. An archived linked conversation of a task already bound to a Session also disables Run now, because the Host refuses that delivery. A run that the Host refuses with `schedule_execution_pending` shows a status notice instead of an error. Deletion requires confirmation, removes the task and its saved records, retains Sessions and already-delivered messages, and reports the result through the existing app-wide toast. The confirmation focuses Cancel first; when it closes, focus returns to the detail menu button. The detail keeps the task until the Host answers. An applied deletion closes the detail or its task tab; a failed deletion keeps the task open for another attempt.
Pause and resume update lifecycle state. While a pause or resume, a catalog reload, or a deletion is in progress, the toggle keeps its look and focus and ignores activation. During a pause, resume, or Run now request, the rule fields reject input without turning grey, and Close and Discard keep their look and ignore activation. Unsaved changes hold pause, resume, Run now, and Delete until you save or discard them. The detail menu offers Run now and Delete. It offers no queued-message cancellation: one inbox message may contain deliveries from several tasks. While a delivery to a reused conversation is queued or running, the detail disables its fields, Save, pause and resume, Run now, and Delete, and states that the task is queued or running and is managed in its conversation, which the bottom conversation entry opens. The minimum delivery interval disables another immediate run until the interval ends, while pause and deletion remain available once execution is idle. When the interval is the only cause, the Run now row stays focusable, and its tooltip states that the minimum run interval has not passed and when Run now becomes available. An archived linked conversation of a task already bound to a Session also disables Run now, because the Host refuses that delivery. A run that the Host refuses with `schedule_execution_pending` shows a status notice instead of an error. Deletion requires confirmation, removes the task and its saved records, retains Sessions and already-delivered messages, and reports the result through the existing app-wide toast. The confirmation focuses Cancel first; when it closes, focus returns to the detail menu button. The detail keeps the task until the Host answers. An applied deletion closes the detail or its task tab; a failed deletion keeps the task open for another attempt.
The list, detail, header, and Session hover show the later of the scheduled occurrence and the Host delivery floor as the next run. Creation and editing show one short minimum-interval hint for per-run conversation targets, read from `schedule.deliveryLimits`; a failed read offers Retry. Scheduled and immediate runs share this limit.
+1 -1
View File
@@ -35,7 +35,7 @@ kind: "package-reference"
重复编辑提供仅一次、每分钟、每小时、每天、每周、每月和自定义规则。日期和时间控件复用已有选择器。引导选项生成 RRULE;分钟和小时间隔从所选时区的当前分钟开始。自定义选项和自定义输入框不提示任何语法名称;输入框接受两行 RRULE 或带显式时区的五字段 Cron。RRULE 在 `DTSTART` 中保留时区;Cron 将表达式和所选时区保留为 Cron 时间类型。复杂 RRULE 保持原文。新的或已修改的自定义文本由共享时间解析器校验,校验通过后才能保存;无效或空输入保留草稿,并说明需要修改的字段。自定义文本无效时,提示手动修改规则、选择其他重复方式,或让智能体修改规则。宿主在保存时执行最终校验。已有 After、At 任务直接显示日期和时间控件;本地时间重复时,必要时使用 UTC 保留准确时点。Every、Daily、Weekly 任务在控件可表示其间隔或钟表时间精度时直接显示对应控件;其他时间规则仍在自定义中显示描述,并在替换前保留原类型。查看任务或编辑其他字段保留其时间规则及已提交发生时点。
暂停和恢复修改生命周期状态。有未保存修改时,先保存或放弃更改,才能暂停、恢复、立即执行或删除。详情菜单提供立即执行和删除,不提供取消排队消息:同一条收件箱消息可能包含多个任务的投递。投递到复用对话的投递排队或执行期间,详情禁用字段、保存、暂停与恢复、立即执行和删除,并说明此任务已排队或执行中,请在会话中管理任务;详情底部的会话入口打开该会话。最小投递间隔结束前禁用再次立即执行,执行空闲后仍可暂停或删除。仅因间隔禁用时,立即执行项仍可聚焦,其提示说明未满最小运行间隔,并给出可以立即执行的时间。已经关联会话的任务,其关联对话已归档时,同样禁用立即执行,因为宿主拒绝这次投递。宿主以 `schedule_execution_pending` 拒绝的立即执行显示为状态提示,而非错误。删除需要确认,移除任务及其已保存记录,保留会话和已经投递的消息,并通过现有应用级提示报告结果。确认对话框首先聚焦“取消”;对话框关闭后,焦点回到详情菜单按钮。宿主答复之前,详情保持显示该任务。删除生效后关闭详情或其任务页签;删除失败时保留任务,以便再次尝试。
暂停和恢复修改生命周期状态。暂停或恢复请求、目录重读或删除进行期间,切换按钮保持原有外观和焦点,并忽略点击。暂停、恢复或立即执行请求期间,规则字段拒绝输入但不变灰,关闭和“放弃”保持原有外观并忽略点击。有未保存修改时,先保存或放弃更改,才能暂停、恢复、立即执行或删除。详情菜单提供立即执行和删除,不提供取消排队消息:同一条收件箱消息可能包含多个任务的投递。投递到复用对话的投递排队或执行期间,详情禁用字段、保存、暂停与恢复、立即执行和删除,并说明此任务已排队或执行中,请在会话中管理任务;详情底部的会话入口打开该会话。最小投递间隔结束前禁用再次立即执行,执行空闲后仍可暂停或删除。仅因间隔禁用时,立即执行项仍可聚焦,其提示说明未满最小运行间隔,并给出可以立即执行的时间。已经关联会话的任务,其关联对话已归档时,同样禁用立即执行,因为宿主拒绝这次投递。宿主以 `schedule_execution_pending` 拒绝的立即执行显示为状态提示,而非错误。删除需要确认,移除任务及其已保存记录,保留会话和已经投递的消息,并通过现有应用级提示报告结果。确认对话框首先聚焦“取消”;对话框关闭后,焦点回到详情菜单按钮。宿主答复之前,详情保持显示该任务。删除生效后关闭详情或其任务页签;删除失败时保留任务,以便再次尝试。
列表、详情、页头和会话悬浮内容的下次运行时间,取计划发生时间和宿主最早允许投递时间中较晚的一项。创建和编辑“每次新建对话”时,通过 `schedule.deliveryLimits` 读取宿主配置,以一句短提示显示最小间隔;读取失败可重试。定时与立即执行共用此限制。
@@ -215,10 +215,13 @@ function TaskDetailView(props: TaskDetailProps): ReactNode {
? JSON.stringify(draft) !== JSON.stringify({ ...initial, cwd: initial.cwd || projects.defaultCwd }) : storedDirty
const issue = dirty ? taskDraftIssue(draft, changes) : undefined
const valid = issue === undefined
const [actionPending, setPending] = useState(false)
const [actionPending, setPending] = useState<'toggle' | 'run' | 'delete' | null>(null)
const [actionFailure, setFailure] = useState<HostEditFailure | HostRunFailure | null>(null)
const saving = cached?.pending === true
const pending = actionPending || saving
const pending = actionPending !== null || saving
// A pause, resume, or run would otherwise flash its controls grey between two enabled states.
const actionHold = actionPending === 'toggle' || actionPending === 'run'
const nativeHold = pending && !actionHold
const failure = !pending && dirty && base !== undefined && task !== undefined && base.updatedAt !== task.updatedAt
? 'host.conflict' : actionFailure ?? cached?.failure ?? null
const [menuOpen, setMenuOpen] = useState(false)
@@ -281,18 +284,20 @@ function TaskDetailView(props: TaskDetailProps): ReactNode {
} else props.actions.setDraft(key, { base, draft, failure: outcome.failed, ...(task === undefined ? { initial } : {}) })
}
const setActive = async (task: ScheduleCatalogEntry): Promise<void> => {
setPending(true)
// The toggle stays focusable while busy, so a second press is ignored here instead of by `disabled`.
if (busy) return
setPending('toggle')
const outcome = await hostActions.edit({ id: task.id, action: 'update', patch: { active: task.status !== 'active' }, expectedUpdatedAt: task.updatedAt ?? null })
if (!mounted.current) return
setPending(false)
setPending(null)
setFailure(outcome === 'saved' ? null : outcome.failed)
}
const run = async (id: ScheduleId): Promise<void> => {
setMenuOpen(false)
setPending(true)
setPending('run')
const outcome = await hostActions.run(id)
if (!mounted.current) return
setPending(false)
setPending(null)
setFailure(outcome === 'queued' ? null : outcome.failed)
}
@@ -321,8 +326,10 @@ function TaskDetailView(props: TaskDetailProps): ReactNode {
</div>}
<div className={css.detailActions}>
{task !== undefined && <><Tooltip label={t(task.status === 'active' ? 'host.pause' : 'host.resume')} side="bottom" delayMs={500} portal>
<span><Button size="sm" className={css.detailIconButton} disabled={busy || dirty || executionActive || task.status === 'ended' || !props.authoritative}
aria-label={t(task.status === 'active' ? 'host.pause' : 'host.resume')} onClick={() => { void setActive(task) }}>
{/* Native disabling while busy would flash the not-allowed cursor and dimmed icon for the length of the request. */}
<span><Button size="sm" className={css.detailIconButton} disabled={dirty || executionActive || task.status === 'ended' || !props.authoritative}
aria-disabled={busy || undefined} aria-label={t(task.status === 'active' ? 'host.pause' : 'host.resume')}
onClick={() => { void setActive(task) }}>
{task.status === 'active' ? <IconPauseOutlineRegular /> : <IconPlayOutlineRegular />}
</Button></span>
</Tooltip>
@@ -346,7 +353,8 @@ function TaskDetailView(props: TaskDetailProps): ReactNode {
</MenuItemButton>
</Menu></>}
{onClose !== undefined && <Button size="sm" className={css.detailIconButton} aria-label={t(task === undefined ? 'host.closeCreate' : 'detail.close')}
disabled={pending} onClick={onClose}><IconCloseOutlineRegular /></Button>}
disabled={nativeHold} aria-disabled={actionHold || undefined}
onClick={() => { if (!actionHold) onClose() }}><IconCloseOutlineRegular /></Button>}
</div>
</div>
<div ref={scrollRef} className={clsx(css.detailScroll, tab === 'records' && css.detailRecords)} role={task === undefined ? undefined : 'tabpanel'}
@@ -380,7 +388,7 @@ function TaskDetailView(props: TaskDetailProps): ReactNode {
</div> : failure === 'host.executionPending' ? <p className={css.detailNotice} role="status">{t(failure)}</p>
: failure !== null && <p className={css.saveFailure} role="alert">{t(failure)}</p>}
<TaskEditorFields draft={draft} onChange={setDraft}
disabled={pending || executionActive || deletingTask}
disabled={pending || executionActive || deletingTask} held={actionHold}
projects={projects} original={base} t={t} />
{draft.target === 'new'
&& <NewSessionLimit load={hostActions.loadNewSessionInterval} t={t} />}
@@ -404,8 +412,8 @@ function TaskDetailView(props: TaskDetailProps): ReactNode {
</footer>}
{(tab === 'rule' || dirty) && <footer className={css.saveFooter}>
<span className={css.saveNotice}>{dirty ? t('rule.unsaved') : ''}</span>
<Button disabled={pending || task !== undefined && !dirty && failure === null}
onClick={() => { reset(); if (task === undefined) onClose?.() }}>{t('rule.cancel')}</Button>
<Button disabled={nativeHold || task !== undefined && !dirty && failure === null} aria-disabled={actionHold || undefined}
onClick={() => { if (actionHold) return; reset(); if (task === undefined) onClose?.() }}>{t('rule.cancel')}</Button>
<Button variant="primary" disabled={busy || !dirty || !valid || failure === 'host.conflict'
|| task !== undefined && (!props.authoritative || executionActive)}
onClick={() => { void save() }}>{t(saving ? task === undefined ? 'host.creating' : 'rule.saving' : task === undefined ? 'host.create' : 'rule.save')}</Button>
@@ -418,12 +426,12 @@ function TaskDetailView(props: TaskDetailProps): ReactNode {
<Button data-modal-autofocus onClick={() => { onConfirm(null) }}>{t('delete.cancel')}</Button>
<Button disabled={busy} onClick={() => {
onConfirm(null)
setPending(true)
setPending('delete')
// The refresh that drops the row lands before the outcome, so the owner retains the task until then.
props.onRetainTask(task)
void onDelete(task.id).then((outcome) => {
if (!mounted.current) return
setPending(false)
setPending(null)
if (outcome === 'deleted') { props.actions.clearDraft(key); onDeleted() }
else props.onRetainTask(null)
})
@@ -121,12 +121,17 @@ export function draftTarget(draft: TaskEditorDraft, original?: ScheduleCatalogEn
return { kind: draft.target, create }
}
/** Render manual creation and inline edits with the existing task rule controls. */
export function TaskEditorFields({ draft, onChange, projects, disabled, original, t }: {
/**
* Render manual creation and inline edits with the existing task rule controls.
* @param props.disabled - whether the fields reject input.
* @param props.held - whether a disabled state keeps the enabled look because it lasts only one brief request.
*/
export function TaskEditorFields({ draft, onChange, projects, disabled, held, original, t }: {
readonly draft: TaskEditorDraft
readonly onChange: (draft: TaskEditorDraft) => void
readonly projects: ProjectChoices
readonly disabled: boolean
readonly held: boolean
readonly original?: ScheduleCatalogEntry | undefined
readonly t: PropsLocale<'schedule.manager'>['t']
}): ReactNode {
@@ -157,7 +162,7 @@ export function TaskEditorFields({ draft, onChange, projects, disabled, original
id: choice.value, label: <span className={css.workspaceMenuItem}><span>{choice.label}</span>
<PathLabel path={choice.value} className={css.workspaceMenuPath} /></span>,
}))
return <fieldset disabled={disabled} className={css.taskEditor} onKeyDownCapture={(event) => {
return <fieldset disabled={disabled} className={held ? `${css.taskEditor} ${css.editorHeld}` : css.taskEditor} onKeyDownCapture={(event) => {
if (event.key !== 'Escape' || event.defaultPrevented || !dateOpen && !clockOpen) return
event.preventDefault(); event.stopPropagation()
if (dateOpen) { setDateOpen(false); dateAnchor.current?.focus() }
@@ -915,8 +915,9 @@
color: var(--dsw-alias-label-tertiary);
}
/* A disabled value stays readable: tertiary rather than dimmed ink. */
.ruleValue:disabled {
/* A disabled value stays readable: tertiary rather than dimmed ink. A held
editor keeps the enabled tone for the length of one brief request. */
.ruleValue:disabled:not(.editorHeld *) {
color: var(--dsw-alias-label-tertiary);
cursor: default;
}
@@ -977,8 +978,7 @@
}
}
/* A disabled value stays readable: tertiary rather than dimmed ink. */
.ruleInput:disabled {
.ruleInput:disabled:not(.editorHeld *) {
color: var(--dsw-alias-label-tertiary);
}
@@ -1074,7 +1074,7 @@
}
}
.ruleIntervalArrow:disabled {
.ruleIntervalArrow:disabled:not(.editorHeld *) {
color: var(--dsw-alias-label-caption);
cursor: default;
}
@@ -1027,6 +1027,72 @@ describe('Host task actions', () => {
expect(screen.getByRole('button', { name: en['host.pause'] }).hasAttribute('disabled')).toBe(false)
})
it.each([
{ name: 'a running execution', entry: { ...task, executionActive: true as const }, filter: undefined, label: en['host.pause'] },
{ name: 'an ended task', entry: { ...task, status: 'ended' as const }, filter: en['status.ended'], label: en['host.resume'] },
])('keeps the toggle natively disabled for $name', ({ entry, filter, label }) => {
mount([entry])
if (filter !== undefined) fireEvent.click(screen.getByRole('button', { name: filter }))
fireEvent.click(screen.getByRole('button', { name: task.title }))
const toggle = screen.getByRole('button', { name: label })
expect(toggle.hasAttribute('disabled')).toBe(true)
expect(toggle.hasAttribute('aria-disabled')).toBe(false)
})
it('holds Pause without native disabling while the Host answers', async () => {
const answer = Promise.withResolvers<'saved'>()
const h = mount([task], { hostActions: hostActions({ edit: vi.fn<HostTaskActions['edit']>(() => answer.promise) }) })
fireEvent.click(screen.getByRole('button', { name: task.title }))
const pause = screen.getByRole('button', { name: en['host.pause'] })
fireEvent.click(pause)
expect(pause.hasAttribute('disabled')).toBe(false)
expect(pause.getAttribute('aria-disabled')).toBe('true')
const fields = screen.getByRole('textbox', { name: en['detail.name'] }).closest('fieldset')!
expect(fields.disabled).toBe(true)
fireEvent.click(pause)
expect(h.props.hostActions.edit).toHaveBeenCalledTimes(1)
await act(async () => { answer.resolve('saved'); await answer.promise })
expect(pause.hasAttribute('aria-disabled')).toBe(false)
expect(fields.disabled).toBe(false)
})
it('holds Close without native disabling while a run is queued', async () => {
const answer = Promise.withResolvers<'queued'>()
mount([task], { hostActions: hostActions({ run: vi.fn<HostTaskActions['run']>(() => answer.promise) }) })
fireEvent.click(screen.getByRole('button', { name: task.title }))
fireEvent.click(screen.getByRole('button', { name: en['detail.more'] }))
fireEvent.click(screen.getByRole('menuitem', { name: en['host.run'] }))
const detail = screen.getByRole('complementary')
const close = within(detail).getByRole('button', { name: en['detail.close'] })
expect(close.hasAttribute('disabled')).toBe(false)
expect(close.getAttribute('aria-disabled')).toBe('true')
fireEvent.click(close)
expect(screen.getByRole('complementary')).toBe(detail)
await act(async () => { answer.resolve('queued'); await answer.promise })
expect(close.hasAttribute('aria-disabled')).toBe(false)
})
it('holds Close and Discard without native disabling while the Host answers', async () => {
const answer = Promise.withResolvers<'saved'>()
const edit = vi.fn<HostTaskActions['edit']>()
.mockResolvedValueOnce({ failed: 'host.saveError' }).mockImplementationOnce(() => answer.promise)
mount([task], { hostActions: hostActions({ edit }) })
fireEvent.click(screen.getByRole('button', { name: task.title }))
await act(async () => { fireEvent.click(screen.getByRole('button', { name: en['host.pause'] })) })
fireEvent.click(screen.getByRole('button', { name: en['host.pause'] }))
const detail = screen.getByRole('complementary')
for (const label of [en['detail.close'], en['rule.cancel']]) {
const button = within(detail).getByRole('button', { name: label })
expect(button.hasAttribute('disabled')).toBe(false)
expect(button.getAttribute('aria-disabled')).toBe('true')
fireEvent.click(button)
}
expect(screen.getByRole('complementary')).toBe(detail)
expect(screen.getByRole('alert').textContent).toBe(en['host.saveError'])
await act(async () => { answer.resolve('saved'); await answer.promise })
expect(within(detail).getByRole('button', { name: en['detail.close'] }).hasAttribute('aria-disabled')).toBe(false)
})
it.each([
{ name: 'queues a run now', outcome: 'queued' as const, alert: null, notice: null },
{ name: 'states a refused run as a wait instead of an error', outcome: { failed: 'host.executionPending' as const },