From 5586cfef7e65052351fce181c064324f227c28f7 Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 29 Sep 2026 16:01:14 +0800 Subject: [PATCH 1/2] fix(ui-schedule): fit the linked Session entry and rename Records tab Shrink the linked Session entry to a chat icon button with a Linked session tooltip when the detail strip cannot fit it, hide the tab list scrollbar, and rename the English Delivery records tab to Records. --- apps/web/tests/schedule-after.e2e.ts | 16 ++-- packages/client/ui-schedule/README.i18n.yaml | 6 +- packages/client/ui-schedule/README.md | 6 +- packages/client/ui-schedule/README.zh.md | 2 +- .../ui-schedule/src/client/TaskDetail.tsx | 90 +++++++++++++++---- .../src/client/TaskManagerPage.module.css | 6 ++ .../src/client/task-manager-locales.ts | 2 +- .../tests/task-manager-page.client.spec.tsx | 81 ++++++++++++++++- 8 files changed, 173 insertions(+), 36 deletions(-) diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index b8b1fe684d..fe298c0032 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -489,8 +489,8 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { const detail = manager.getByRole('complementary', { name: 'Task details' }) expect(await detail.getByRole('tab', { name: 'Rules', exact: true }).getAttribute('aria-selected')).toBe('true') expect(await detail.getByRole('region', { name: 'Saved delivery record' }).count()).toBe(0) - await detail.getByRole('tab', { name: 'Delivery records', exact: true }).click() - await detail.getByRole('tabpanel', { name: 'Delivery records', exact: true }).waitFor() + await detail.getByRole('tab', { name: 'Records', exact: true }).click() + await detail.getByRole('tabpanel', { name: 'Records', exact: true }).waitFor() const receipt = detail.getByRole('region', { name: 'Saved delivery record' }) await receipt.waitFor() expect(await detail.getByText('Earlier delivery records have been cleared', { exact: true }).count()).toBe(0) @@ -575,9 +575,9 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { const manager = page.getByTestId('task-manager-page') await manager.getByRole('list', { name: 'Task catalog' }).getByRole('button', { name: original.title, exact: true }).click() const detail = manager.getByRole('complementary', { name: 'Task details' }) - await detail.getByRole('tab', { name: 'Delivery records', exact: true }).click() + await detail.getByRole('tab', { name: 'Records', exact: true }).click() const receipts = detail.getByRole('region', { name: 'Saved delivery record' }) - const recordsPanel = detail.getByRole('tabpanel', { name: 'Delivery records', exact: true }) + const recordsPanel = detail.getByRole('tabpanel', { name: 'Records', exact: true }) await expect.poll(() => receipts.count()).toBe(1) expect(await detail.getByText('Earlier delivery records have been cleared', { exact: true }).count()).toBe(0) @@ -604,7 +604,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { // Each saved occurrence renders its own scheduled instant. expect(await recordsPanel.locator(`time[datetime="${record.scheduledAt}"]`).count()).toBe(1) } - expect(await detail.getByRole('tab', { name: 'Delivery records', exact: true }).getAttribute('aria-selected')).toBe('true') + expect(await detail.getByRole('tab', { name: 'Records', exact: true }).getAttribute('aria-selected')).toBe('true') await detail.getByRole('tab', { name: 'Rules', exact: true }).click() // The interval row states its quantity in the friendly unit the stored // seconds select: 3600 seconds is one whole hour, so the row shows 1 and @@ -621,7 +621,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { kind: 'every', everySeconds: EVERY_INTERVAL_SECONDS * 2, status: 'active', }) expect(await scaffold.ctx.schedule.history({ sessionId: everyHandle.agent.id, id: original.id, limit: 20 })).toEqual(saved) - await detail.getByRole('tab', { name: 'Delivery records', exact: true }).click() + await detail.getByRole('tab', { name: 'Records', exact: true }).click() await expect.poll(() => receipts.count()).toBe(2) // The linked-session row renders on the Rules view only. await detail.getByRole('tab', { name: 'Rules', exact: true }).click() @@ -789,7 +789,7 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { const details = manager.getByRole('complementary', { name: 'Task details' }) expect(await details.textContent()).toContain(otherSessionId) expect(scaffold.ctx.agents.get(otherSessionId)).toBeUndefined() - await details.getByRole('tab', { name: 'Delivery records', exact: true }).click() + await details.getByRole('tab', { name: 'Records', exact: true }).click() await details.getByText('No delivery record available', { exact: true }).waitFor() expect(await details.getByRole('region', { name: 'Saved delivery record' }).count()).toBe(0) expect(scaffold.ctx.agents.get(otherSessionId)).toBeUndefined() @@ -1036,7 +1036,7 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { await manager.getByRole('searchbox', { name: 'Search tasks' }).fill(record.title) await manager.getByRole('list', { name: 'Task catalog' }).getByRole('button', { name: record.title, exact: true }).click() const detail = manager.getByRole('complementary', { name: 'Task details' }) - const recordsTab = detail.getByRole('tab', { name: 'Delivery records', exact: true }) + const recordsTab = detail.getByRole('tab', { name: 'Records', exact: true }) const rulesTab = detail.getByRole('tab', { name: 'Rules', exact: true }) const notice = detail.getByText('Earlier delivery records have been cleared', { exact: true }) await recordsTab.click() diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index 8866d6e127..0a2a8b77ea 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/packages/client/ui-schedule/README.i18n.yaml @@ -15,13 +15,13 @@ en: d152484eb41ac6b4 zh: 405b30feaf48c087 /deepseek-ai-dsh-client-ui-schedule/use-this-package: - en: c129ae5d0a150a5d - zh: d81976f2840cb059 + en: bb15c9b51b34bb95 + zh: 681617356aec72ce /deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source: en: 6bd6882dbbbefcb0 zh: 03aa15f1a65d13dc /deepseek-ai-dsh-client-ui-schedule/understand-the-implementation: - en: f863859edc1cb0f7 + en: 5bbf2bc1458455c3 zh: 2bf04cdd1f17bb18 /deepseek-ai-dsh-client-ui-schedule/further-exploration: en: 06e265120b05dbf5 diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index ed9431a6b5..c4de8ca3a7 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -25,7 +25,7 @@ Manage active and inactive tasks from the Automation tasks page and the right Si ## Use this package -The shipped Web composition carries no `ui-schedule` row; enable the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` from the Plugins page (Official group), or list it in a profile's `dsh.profile.bundles`, to insert and mount `ui-schedule` with the Host Schedule service and the clock context that supplies the model's current time. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Its task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state keeps the heading's New action beside the notice. Selecting an active or inactive task opens a second detail level with Rules and Delivery records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation. +The shipped Web composition carries no `ui-schedule` row; enable the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` from the Plugins page (Official group), or list it in a profile's `dsh.profile.bundles`, to insert and mount `ui-schedule` with the Host Schedule service and the clock context that supplies the model's current time. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Its task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state keeps the heading's New action beside the notice. Selecting an active or inactive task opens a second detail level with Rules and Records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. When the strip cannot fit the tab list beside the labelled entry, the entry becomes an icon button styled like More task actions, with the Session chat icon and a Linked session tooltip; the tab list scrolls without a visible scrollbar. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation. Selecting a task from the list opens Rules; refreshing that task preserves the selected tab. Rules edits an active task's name, instruction, and run time; the edits stay in a local draft until saved, and inactive tasks remain read-only. Unmodified Left/Right arrows cycle the tabs, Home/End select the first/last tab, and keyboard focus follows the selection. The tabs do not consume these keys with Alt, Ctrl, Meta, or Shift. The linked-Session control in the detail tab strip stays available on the Rules tab, and confirmed deletion is available from either view. @@ -33,7 +33,7 @@ Rules shows the rule in a Run time card whose rows edit a local draft: Repeat is The rows are disabled while a save is in flight, while the catalog is loading, during a deletion, and for an inactive task. A save bar appears only while the local draft differs from the stored task: it shows Unsaved changes with Cancel and Save changes, and Save changes becomes Saving… while the save is in flight. Cancel restores the stored values. A catalog refresh becomes the shown values only while the draft has no unsaved edit, so an unsaved draft survives an authoritative refresh. A failed save keeps the draft and shows a localized message; after a conflict, retry against the refreshed record. If the task disappears from the catalog, Delete is disabled until the task returns. A successful update refreshes the authoritative catalog. A rejected or unconfirmed update is distinct from a catalog-read failure: Retry reloads the catalog without presenting the update as failed. If the update itself cannot be confirmed, check the task before retrying. Late responses after switching tasks or closing details do not replace the current view, and no failure rolls back a Host write already begun. -Opening Delivery records lazily requests the newest 20 saved records. Load older records adds another page in newest-first append order, independent of wall-clock rollback. The tab strip is the whole header of that view: the editable name and next-run line belong to Rules. Each Saved delivery record leads with a clock glyph and its occurrence time as the locale's month name and clock field pair in the task's zone, or the browser's zone for a one-shot record, and shows the actual saved prompt when present. A prompt longer than two lines shows its first two lines and an Expand toggle, which Collapse reverses; a collapsed prompt rechecks its length when the panel width changes. A legacy receipt without a prompt never substitutes the current instruction. +Opening Records lazily requests the newest 20 saved records. Load older records adds another page in newest-first append order, independent of wall-clock rollback. The tab strip is the whole header of that view: the editable name and next-run line belong to Rules. Each Saved delivery record leads with a clock glyph and its occurrence time as the locale's month name and clock field pair in the task's zone, or the browser's zone for a one-shot record, and shows the actual saved prompt when present. A prompt longer than two lines shows its first two lines and an Expand toggle, which Collapse reverses; a collapsed prompt rechecks its length when the panel width changes. A legacy receipt without a prompt never substitutes the current instruction. After the last saved page loads, a nonempty history with confirmed pruning shows a borderless notice at the bottom left of the detail panel. Its info button expands or collapses the current Host retention rules. Empty history, legacy unavailability without confirmed pruning, incomplete pagination, and failed reads show no cleanup notice. @@ -65,7 +65,7 @@ The browser entry registers the `schedules` main panel and matching sidebar entr A restored task tab carries no navigation parameters, because the Sidebar persists a tab's layout record and not what its opener passed. The page type therefore keeps its own binding from the Session and the layout record's id to the Session and id of the task that record last showed, stored under one `dsh.schedule.task-tab.v1.` localStorage key per Session with one entry per tab id and never any task content. Its body and chip read the entry only for a record without navigation parameters, remove it once a read that succeeded after the tab appeared cannot resolve it, and report no missing task on a binding's behalf. Once such a read leaves the tab without a task identity, the body names that state instead of reporting a load that never finishes. -Subscriptions exist only while the framework observes a source. The final unsubscribe releases event listeners and invalidates pending responses. Components receive framework-bound `useCatalog` hooks and explicit action callbacks. The mounted Delivery records view reads `schedule.history` with the selected task's original Session binding; catalog and list responses omit full history. Components own disposable search, filtering, selection, confirmation, and history-page state. Reminder records remain Host-owned and do not come from historical Session projections or browser-local demo data. +Subscriptions exist only while the framework observes a source. The final unsubscribe releases event listeners and invalidates pending responses. Components receive framework-bound `useCatalog` hooks and explicit action callbacks. The mounted Records view reads `schedule.history` with the selected task's original Session binding; catalog and list responses omit full history. Components own disposable search, filtering, selection, confirmation, and history-page state. Reminder records remain Host-owned and do not come from historical Session projections or browser-local demo data. No runtime invariant companion is published because the catalogs render Host-owned task state and own only disposable query and interaction state. diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index d03aefa658..d1405a2e9a 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -随发行版交付的 Web 组合不含 `ui-schedule` 行;在插件管理页的“官方”分组中启用可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle`,或把它列进 profile 的 `dsh.profile.bundles`,即可插入 `ui-schedule` 并将它与 Host Schedule 服务、以及为模型提供当前时间的时钟上下文一起挂载。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示旁保留页头的“新建”操作。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。 +随发行版交付的 Web 组合不含 `ui-schedule` 行;在插件管理页的“官方”分组中启用可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle`,或把它列进 profile 的 `dsh.profile.bundles`,即可插入 `ui-schedule` 并将它与 Host Schedule 服务、以及为模型提供当前时间的时钟上下文一起挂载。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示旁保留页头的“新建”操作。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。页签条放不下页签列表和带文字的入口时,入口收为与“更多任务操作”同样式的图标按钮,使用会话对话图标,悬停提示“关联会话”;页签列表可滚动但不显示滚动条。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。 从列表选择任务时默认打开“规则”;刷新同一任务会保留当前页签。活动任务的“规则”修改名称、指令和运行时间;这些修改保留在本地草稿中直到保存,未运行任务保持只读。不带修饰键的左右方向键循环切换页签,Home/End 选择第一个/最后一个页签,键盘焦点随选择移动。页签不会拦截这些键与 Alt、Ctrl、Meta 或 Shift 的组合。共用的会话入口位于详情页签条内,在“规则”页签可见;确认删除操作在两种页签下都可用。 diff --git a/packages/client/ui-schedule/src/client/TaskDetail.tsx b/packages/client/ui-schedule/src/client/TaskDetail.tsx index eb3a2f1b36..b5e7a77774 100644 --- a/packages/client/ui-schedule/src/client/TaskDetail.tsx +++ b/packages/client/ui-schedule/src/client/TaskDetail.tsx @@ -1,12 +1,12 @@ /** One retained task's rule, saved deliveries, run-time edits, deletion, and original-Session link. */ import { assertNever } from '@deepseek-ai/dsh-util-values' -import { useEffect, useId, useMemo, useRef, useState } from 'react' +import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { CSSProperties, KeyboardEvent, ReactNode } from 'react' import clsx from 'clsx' import { Button, IconChevronDownOutlineRegular, IconChevronRightOutlineRegular, IconChevronUpOutlineRegular, IconClockOutlineRegular, IconCloseOutlineRegular, - IconEllipsisOutlineRegular, IconTrashOutlineRegular, Modal, Pill, + IconEllipsisOutlineRegular, IconTrashOutlineRegular, Modal, Pill, ReferenceIconRegular, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' @@ -308,6 +308,46 @@ export function TaskDetail({ const sessionLink = sessionLinkState(task.sessionId, sessions, workspaces) const linkedSession = sessionLabel(task.sessionId, sessions) const sessionNotice = sessionLink === 'available' ? undefined : t(sessionLinkMessages[sessionLink]) + const linkedShown = tab === 'rule' && withinSession !== task.sessionId + const linkedText = t('detail.session') + const [linkedCompact, setLinkedCompact] = useState(false) + const linkedRef = useRef(null) + // The expanded entry's width for its current label, measured while it last + // rendered expanded; the compact icon cannot report it. + const linkedExpanded = useRef<{ label: string; width: number } | undefined>(undefined) + const linkedAction = linkedSession.titled + ? t('detail.openSessionTitle', { title: linkedSession.text }) + : t('detail.openSession') + // The Session name is the entry's visually hidden accessible description in both layouts. + const linkedName = {linkedSession.text} + const linkedDescribedBy = `${id}-session${sessionNotice === undefined ? '' : ` ${id}-session-state`}` + useLayoutEffect(() => { + if (!linkedShown) return + const tabs = detailTabsRef.current as HTMLDivElement + const linked = linkedRef.current as HTMLSpanElement + const measure = (): void => { + const width = linked.getBoundingClientRect().width + if (!linkedCompact) linkedExpanded.current = { label: linkedText, width } + const expanded = linkedExpanded.current + // A label changed while compact has no measured expanded width yet. + if (expanded?.label !== linkedText) { + setLinkedCompact(false) + return + } + // The tab list grows into the strip's free width, so its own width cannot + // tell whether its tabs overflow; the tabs' extent can. The expanded entry + // fits only when that extent still fits in the width it would leave. + const extent = (tabs.lastElementChild as Element).getBoundingClientRect().right + - (tabs.firstElementChild as Element).getBoundingClientRect().left + // Expanding again takes 1px more room than collapsing, so whole-pixel + // clientWidth rounding cannot switch the two layouts back and forth. + setLinkedCompact(extent > tabs.clientWidth - (expanded.width - width) - (linkedCompact ? 1 : 0)) + } + measure() + const observer = new ResizeObserver(measure) + observer.observe(tabs) + return () => { observer.disconnect() } + }, [linkedShown, linkedCompact, linkedText]) const taskIdentity = `${task.sessionId}\u0000${task.id}` const [edit, setEdit] = useState(() => initialRuleEdit(task)) // The elapsed-interval unit lives here, not in the Run time card, so the saved @@ -688,23 +728,35 @@ export function TaskDetail({ tabs and the task's own actions, so it stays visible while the detail scrolls. Rules-only: the records view has no linked row. A detail hosted inside the linked Session itself omits the entry. */} - {tab === 'rule' && withinSession !== task.sessionId && - + {linkedShown && + {linkedCompact + ? + + + : } } {/* The menu closes Escape from its own document keydown listener; this wrapper covers a menu whose own listener stands down, where the page's diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css index 595a07864f..74e5164b47 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css @@ -565,6 +565,12 @@ padding-bottom: 1px; margin-bottom: -1px; overflow-x: auto; + /* A narrow strip still scrolls its tabs, without a visible bar (both engines). */ + scrollbar-width: none; +} + +.detailTabs::-webkit-scrollbar { + display: none; } .detailActions { diff --git a/packages/client/ui-schedule/src/client/task-manager-locales.ts b/packages/client/ui-schedule/src/client/task-manager-locales.ts index 6906e54fff..f5e4811727 100644 --- a/packages/client/ui-schedule/src/client/task-manager-locales.ts +++ b/packages/client/ui-schedule/src/client/task-manager-locales.ts @@ -28,7 +28,7 @@ export const en = { 'detail.nextRun': 'Next run', 'detail.tabs': 'Task detail views', 'detail.rule': 'Rules', - 'detail.records': 'Delivery records', + 'detail.records': 'Records', 'delivery.empty': 'No delivery record available', 'delivery.loading': 'Loading delivery records…', 'delivery.error': 'Could not load delivery records.', diff --git a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx index e03520784c..499f8209dc 100644 --- a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx +++ b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx @@ -213,7 +213,7 @@ function emptyNewTaskButton(dictionary: typeof en | typeof zh = en): HTMLElement function detailViewLabels(dictionary: typeof en | typeof zh) { return dictionary === en ? { - tablist: 'Task detail views', rules: 'Rules', records: 'Delivery records', + tablist: 'Task detail views', rules: 'Rules', records: 'Records', empty: 'No delivery record available', } : { @@ -3399,6 +3399,85 @@ describe('Task detail rule header and run-time card', () => { expect(detailContext(detail)).not.toBeNull() expect(within(detail).getByRole('button', { name: en['detail.openSession'] })).toBeDefined() }) + + it('shrinks the linked Session entry to an icon with a tooltip while the strip cannot fit it', () => { + class FakeResizeObserver implements ResizeObserver { + static readonly made: FakeResizeObserver[] = [] + readonly observe = vi.fn<(target: Element) => void>() + readonly unobserve = vi.fn() + readonly disconnect = vi.fn() + constructor(private readonly callback: ResizeObserverCallback) { + FakeResizeObserver.made.push(this) + } + + fire(): void { + this.callback([], this) + } + } + vi.stubGlobal('ResizeObserver', FakeResizeObserver) + const stripObserver = (): FakeResizeObserver => FakeResizeObserver.made + .findLast(observer => observer.observe.mock.calls.some(([target]) => target.getAttribute('role') === 'tablist'))! + // The tab list needs 120px; the expanded entry takes 120px and the icon 28px, + // and the tabs get the rest of the strip. + let strip = 220 + const compact = (): boolean => document.querySelector(`.${css.detailContext} .${css.detailIconButton}`) !== null + const entryWidth = (): number => (compact() ? 28 : 120) + // The tab list itself grows into the strip, so only its tabs report the 120px. + vi.spyOn(Element.prototype, 'clientWidth', 'get').mockImplementation(() => strip - entryWidth()) + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) { + if (this.classList.contains(css.detailContext!)) return new DOMRect(0, 0, entryWidth(), 28) + if (this.getAttribute('role') === 'tab') return new DOMRect(this.getAttribute('data-detail-tab') === 'rule' ? 0 : 80, 0, 40, 30) + return new DOMRect(0, 0, 0, 0) + }) + try { + const h = mount({ records: [at] }) + fireEvent.click(screen.getByRole('button', { name: 'Review release' })) + const detail = screen.getByRole('complementary', { name: en['detail.label'] }) + const link = (): HTMLElement => within(detailContext(detail)!).getByRole('button', { name: en['detail.openSession'] }) + expect(compact()).toBe(true) + expect(link().className).toBe(within(detail).getByRole('button', { name: en['detail.more'] }).className) + expect(within(link()).queryByText(en['detail.session'])).toBeNull() + expect(within(link()).getByText(at.sessionId)).toBeDefined() + fireEvent.mouseEnter(link()) + expect(document.querySelector('[role="tooltip"]')?.textContent).toBe(en['detail.session']) + fireEvent.click(link()) + expect(h.props.onOpenSession).toHaveBeenCalledExactlyOnceWith(at.sessionId) + + // The expanded entry needs 120px beside the tabs' 120px, plus 1px to expand again. + strip = 240 + act(() => { stripObserver().fire() }) + expect(compact()).toBe(true) + strip = 241 + act(() => { stripObserver().fire() }) + expect(compact()).toBe(false) + strip = 240 + act(() => { stripObserver().fire() }) + expect(compact()).toBe(false) + expect(within(link()).getByText(en['detail.session'])).toBeDefined() + + // A label changed while compact is measured expanded before shrinking again. + strip = 200 + act(() => { stripObserver().fire() }) + expect(compact()).toBe(true) + h.view.rerender() + expect(compact()).toBe(true) + expect(within(detail).getByRole('button', { name: zh['detail.openSession'] })).toBeDefined() + + const observer = stripObserver() + fireEvent.click(within(detail).getByRole('tab', { name: detailViewLabels(zh).records })) + expect(detailContext(detail)).toBeNull() + expect(observer.disconnect).toHaveBeenCalledTimes(1) + } finally { + vi.restoreAllMocks() + vi.unstubAllGlobals() + } + }) + + it('scrolls the detail tabs without a visible scrollbar', () => { + const sheet = readFileSync(resolve(import.meta.dirname, '../src/client/TaskManagerPage.module.css'), 'utf8') + expect(sheet).toMatch(/\.detailTabs \{[^}]*overflow-x: auto;[^}]*scrollbar-width: none;/) + expect(sheet).toMatch(/\.detailTabs::-webkit-scrollbar \{\s*display: none;/) + }) }) describe('Task detail name and instruction edits', () => { From d8f38c4ad6a1b1aafe9b957593aabf7bf43b070d Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 29 Sep 2026 16:23:52 +0800 Subject: [PATCH 2/2] fix(ui-schedule): keep linked Session focus and disabled tooltip Render one Button for both layouts so crossing the fit threshold keeps focus, anchor the tooltip on the wrapper so a disabled compact entry still names itself, observe each tab, and rename the tab in the user and subsystem docs. --- docs/subsystems/schedule.i18n.yaml | 4 +- docs/subsystems/schedule.md | 2 +- docs/subsystems/schedule.zh.md | 2 +- docs/user/guide/schedule.i18n.yaml | 4 +- docs/user/guide/schedule.md | 2 +- docs/user/guide/schedule.zh.md | 2 +- packages/client/ui-schedule/README.i18n.yaml | 4 +- packages/client/ui-schedule/README.md | 2 +- packages/client/ui-schedule/README.zh.md | 2 +- .../ui-schedule/src/client/TaskDetail.tsx | 58 ++++++++++--------- .../src/client/TaskManagerPage.module.css | 10 ++++ .../tests/task-manager-page.client.spec.tsx | 29 +++++++++- 12 files changed, 78 insertions(+), 43 deletions(-) diff --git a/docs/subsystems/schedule.i18n.yaml b/docs/subsystems/schedule.i18n.yaml index 614eb31d1e..c5ccc3f0c5 100644 --- a/docs/subsystems/schedule.i18n.yaml +++ b/docs/subsystems/schedule.i18n.yaml @@ -27,8 +27,8 @@ en: 0656173de50ee132 zh: 54176540371d0f28 /host-wide-schedule/web-catalog: - en: 9955c4857340103a - zh: edfa0491c8a08db7 + en: 1812b61ec3a5ba65 + zh: 5a066b6ebd5d9a3a /host-wide-schedule/timing-edits: en: ce5d2095a3ad2e82 zh: 167b3715f8eaffc1 diff --git a/docs/subsystems/schedule.md b/docs/subsystems/schedule.md index 4d8f4a1c0e..c252efac25 100644 --- a/docs/subsystems/schedule.md +++ b/docs/subsystems/schedule.md @@ -339,7 +339,7 @@ type ScheduleCatalogEntry = ScheduleRecord & { The Remote `schedule.list({ sessionId })`, model `schedule_list`, and Session-header catalog return only active tasks. A model view's derived timing `state` remains distinct from stored lifecycle `status`. Deletion uses `schedule.delete({ sessionId, id })` with the entry's original binding; a mismatched binding returns not found. Model tools supply the current Agent's Session, whereas the global user interface supplies the selected task's binding. The binding check alone does not establish caller authorization. The payload-free `schedule/changed` event invalidates client lists; reconnecting clients fetch current state again. -The shipped Web composition carries no `ui-schedule` row; the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle`, enabled from the Plugins page, inserts and mounts `ui-schedule` with the Host capability. The [client package](../../packages/client/ui-schedule/README.md) owns the catalog, empty state, and deletion controls. The page separately filters all, active, and inactive tasks, retains inactive details and an original-Session control in the detail tab strip, and requires explicit confirmed deletion. Rules and Delivery records separate task settings from lazily paged saved receipts. Neither a receipt nor inactive status confirms model execution. +The shipped Web composition carries no `ui-schedule` row; the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle`, enabled from the Plugins page, inserts and mounts `ui-schedule` with the Host capability. The [client package](../../packages/client/ui-schedule/README.md) owns the catalog, empty state, and deletion controls. The page separately filters all, active, and inactive tasks, retains inactive details and an original-Session control in the detail tab strip, and requires explicit confirmed deletion. The Rules and Records tabs separate task settings from lazily paged saved receipts. Neither a receipt nor inactive status confirms model execution. ## Timing edits diff --git a/docs/subsystems/schedule.zh.md b/docs/subsystems/schedule.zh.md index eff76ade44..80603e4039 100644 --- a/docs/subsystems/schedule.zh.md +++ b/docs/subsystems/schedule.zh.md @@ -339,7 +339,7 @@ type ScheduleCatalogEntry = ScheduleRecord & { Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。 -随发行版交付的 Web 组合不含 `ui-schedule` 行;在插件管理页启用可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle` 会插入它并与宿主能力一起挂载。[客户端包](../../packages/client/ui-schedule/README.zh.md) 负责目录、空状态与删除控件。页面单独筛选全部、活动和未运行任务,保留未运行任务详情及详情页签条内的原 Session 入口,并要求显式确认删除。“规则”和“发送记录”将任务设置与按需分页加载的已保存回执分开。回执与未运行状态均不确认模型执行。 +随发行版交付的 Web 组合不含 `ui-schedule` 行;在插件管理页启用可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle` 会插入它并与宿主能力一起挂载。[客户端包](../../packages/client/ui-schedule/README.zh.md) 负责目录、空状态与删除控件。页面单独筛选全部、活动和未运行任务,保留未运行任务详情及详情页签条内的原 Session 入口,并要求显式确认删除。“规则”和“任务运行记录”页签将任务设置与按需分页加载的已保存回执分开。回执与未运行状态均不确认模型执行。 ## 修改时间 diff --git a/docs/user/guide/schedule.i18n.yaml b/docs/user/guide/schedule.i18n.yaml index 1a1a1c08f9..12c7b8259d 100644 --- a/docs/user/guide/schedule.i18n.yaml +++ b/docs/user/guide/schedule.i18n.yaml @@ -15,8 +15,8 @@ en: 68763ffeedaeea3c zh: 739af012a337d448 /schedule-reminders/inspect-and-delete-tasks: - en: a44fc1aabc2ab957 - zh: 31f444d4c7fd5c7a + en: b74bc63a1273680b + zh: b6d806e91c361b99 /schedule-reminders/edit-an-active-task: en: b3426f5dbc185958 zh: ccf118e2415a5e77 diff --git a/docs/user/guide/schedule.md b/docs/user/guide/schedule.md index 97db8089b0..6c2dc25aaa 100644 --- a/docs/user/guide/schedule.md +++ b/docs/user/guide/schedule.md @@ -28,7 +28,7 @@ For example, ask: “Remind me every day at 23:00 in Asia/Shanghai to check the Open Automation tasks from the sidebar to view reminders across Sessions without activating their conversations. Search by the stored task name, the instruction, or the task's internal Session id, and filter by the single All, Enabled, or Inactive status row. An idle, unarchived Session row whose Session has at least one active task shows a clock mark; an archived row keeps that cell blank, with its live status on the hover card only. A long hover on a Session row with active tasks lists up to two of those tasks with their frequency and their next run as a device-zone time with its relative duration. The open Session's header shows an icon-only reminder clock while active reminders exist: it opens the sole task's details directly, or a list of the Session's reminders otherwise. -Select a task to open Rules, then choose Delivery records to load the newest 20 saved records. Each saved record reads as one compact row: a clock glyph, the occurrence time in the task's saved zone (the browser's zone for a one-shot or interval task), and the saved instruction when one was stored. Records without a saved instruction never show the current instruction in its place. Load older records adds the next page. Records follow newest-first saved order even if the clock moves backward; they do not describe model execution results. The original Session entry sits in the detail tab strip, so it stays visible while the Rules view scrolls. Unmodified Left/Right arrows and Home/End switch the focused tabs; Alt, Ctrl, Meta, and Shift combinations are not intercepted. Refreshing the task preserves the selected tab. +Select a task to open Rules, then choose Records to load the newest 20 saved records. Each saved record reads as one compact row: a clock glyph, the occurrence time in the task's saved zone (the browser's zone for a one-shot or interval task), and the saved instruction when one was stored. Records without a saved instruction never show the current instruction in its place. Load older records adds the next page. Records follow newest-first saved order even if the clock moves backward; they do not describe model execution results. The original Session entry sits in the detail tab strip, so it stays visible while the Rules view scrolls. Unmodified Left/Right arrows and Home/End switch the focused tabs; Alt, Ctrl, Meta, and Shift combinations are not intercepted. Refreshing the task preserves the selected tab. An empty record view means a successful query returned no saved records. Loading, request failure, a missing task, and an invalid older-record cursor have separate messages. Use Retry delivery records after a failed request or missing-task response; use Refresh delivery records after an invalid cursor to reload the newest page. Previously loaded records may remain visible with a warning while a request fails. A new latest delivery refreshes the newest page. Late responses for a task or view you have left do not replace the current view. diff --git a/docs/user/guide/schedule.zh.md b/docs/user/guide/schedule.zh.md index fcfbfc1082..531bee4686 100644 --- a/docs/user/guide/schedule.zh.md +++ b/docs/user/guide/schedule.zh.md @@ -28,7 +28,7 @@ 从侧栏打开自动化任务页面,可跨 Session 查看提醒,而不激活相应对话。按任务已存储的名称、指令或任务所属的内部 Session id 搜索,并用唯一的“全部/已开启/未运行”状态筛选行过滤。Session 行在其 Session 有至少一个活动任务、自身空闲且未归档时显示时钟标识;归档行该格留空,其实时状态只在悬浮卡片中显示。悬停任一带活动任务的 Session 行时,最多列出其中两个任务及其频率,以及设备时区下的下次运行时间与相对时长。已打开 Session 的顶部在有活动提醒时显示仅图标时钟入口:只有一个任务时直接打开其详情,否则列出该 Session 的提醒。 -选择任务打开“规则”,再选择“发送记录”加载最新的 20 条已保存记录。每条已保存记录为一行紧凑信息:时钟图标、任务保存时区下的发生时间(一次性或间隔任务则为浏览器时区),以及保存过的指令。没有保存指令的记录不会用当前指令顶替。“加载更早的记录”增加下一页。记录按保存顺序从新到旧排列,即使时钟回拨也不改变顺序;它们不描述模型执行结果。原会话入口位于详情页签条内,因此在“规则”视图滚动时都保持可见。不带修饰键的左右方向键和 Home/End 可切换已聚焦的页签;不会拦截与 Alt、Ctrl、Meta 或 Shift 的组合。刷新任务会保留当前页签。 +选择任务打开“规则”,再选择“任务运行记录”加载最新的 20 条已保存记录。每条已保存记录为一行紧凑信息:时钟图标、任务保存时区下的发生时间(一次性或间隔任务则为浏览器时区),以及保存过的指令。没有保存指令的记录不会用当前指令顶替。“加载更早的记录”增加下一页。记录按保存顺序从新到旧排列,即使时钟回拨也不改变顺序;它们不描述模型执行结果。原会话入口位于详情页签条内,因此在“规则”视图滚动时都保持可见。不带修饰键的左右方向键和 Home/End 可切换已聚焦的页签;不会拦截与 Alt、Ctrl、Meta 或 Shift 的组合。刷新任务会保留当前页签。 记录空态表示查询成功且没有返回已保存记录。加载中、请求失败、任务不存在和较早记录游标无效分别显示不同提示。请求失败或返回任务不存在时,使用“重试加载发送记录”;游标无效时,使用“刷新发送记录”重新加载最新一页。请求失败时,此前加载的记录可能仍会显示,并附有警告。新的最近一次投递会刷新最新一页。离开任务或视图后才到达的响应不会替换当前视图。 diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index 0a2a8b77ea..b6c11dfaab 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/packages/client/ui-schedule/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 405b30feaf48c087 /deepseek-ai-dsh-client-ui-schedule/use-this-package: - en: bb15c9b51b34bb95 - zh: 681617356aec72ce + en: b9f8449e78936e0d + zh: 9822c7105d1ed04c /deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source: en: 6bd6882dbbbefcb0 zh: 03aa15f1a65d13dc diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index c4de8ca3a7..5b68802156 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -25,7 +25,7 @@ Manage active and inactive tasks from the Automation tasks page and the right Si ## Use this package -The shipped Web composition carries no `ui-schedule` row; enable the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` from the Plugins page (Official group), or list it in a profile's `dsh.profile.bundles`, to insert and mount `ui-schedule` with the Host Schedule service and the clock context that supplies the model's current time. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Its task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state keeps the heading's New action beside the notice. Selecting an active or inactive task opens a second detail level with Rules and Records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. When the strip cannot fit the tab list beside the labelled entry, the entry becomes an icon button styled like More task actions, with the Session chat icon and a Linked session tooltip; the tab list scrolls without a visible scrollbar. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation. +The shipped Web composition carries no `ui-schedule` row; enable the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` from the Plugins page (Official group), or list it in a profile's `dsh.profile.bundles`, to insert and mount `ui-schedule` with the Host Schedule service and the clock context that supplies the model's current time. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Its task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state keeps the heading's New action beside the notice. Selecting an active or inactive task opens a second detail level with Rules and Records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. When the strip cannot fit the tab list beside the labelled entry, the entry becomes an icon button with the same button style as More task actions, with the Session chat icon and a Linked session tooltip that also shows while the entry is disabled; the entry keeps focus when it switches layouts; the tab list scrolls without a visible scrollbar. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation. Selecting a task from the list opens Rules; refreshing that task preserves the selected tab. Rules edits an active task's name, instruction, and run time; the edits stay in a local draft until saved, and inactive tasks remain read-only. Unmodified Left/Right arrows cycle the tabs, Home/End select the first/last tab, and keyboard focus follows the selection. The tabs do not consume these keys with Alt, Ctrl, Meta, or Shift. The linked-Session control in the detail tab strip stays available on the Rules tab, and confirmed deletion is available from either view. diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index d1405a2e9a..63c93ba5ef 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -随发行版交付的 Web 组合不含 `ui-schedule` 行;在插件管理页的“官方”分组中启用可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle`,或把它列进 profile 的 `dsh.profile.bundles`,即可插入 `ui-schedule` 并将它与 Host Schedule 服务、以及为模型提供当前时间的时钟上下文一起挂载。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示旁保留页头的“新建”操作。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。页签条放不下页签列表和带文字的入口时,入口收为与“更多任务操作”同样式的图标按钮,使用会话对话图标,悬停提示“关联会话”;页签列表可滚动但不显示滚动条。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。 +随发行版交付的 Web 组合不含 `ui-schedule` 行;在插件管理页的“官方”分组中启用可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle`,或把它列进 profile 的 `dsh.profile.bundles`,即可插入 `ui-schedule` 并将它与 Host Schedule 服务、以及为模型提供当前时间的时钟上下文一起挂载。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示旁保留页头的“新建”操作。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。页签条放不下页签列表和带文字的入口时,入口收为与“更多任务操作”同样式的图标按钮,使用会话对话图标,悬停提示“关联会话”,入口禁用时也会显示;切换形态时保留焦点;页签列表可滚动但不显示滚动条。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。 从列表选择任务时默认打开“规则”;刷新同一任务会保留当前页签。活动任务的“规则”修改名称、指令和运行时间;这些修改保留在本地草稿中直到保存,未运行任务保持只读。不带修饰键的左右方向键循环切换页签,Home/End 选择第一个/最后一个页签,键盘焦点随选择移动。页签不会拦截这些键与 Alt、Ctrl、Meta 或 Shift 的组合。共用的会话入口位于详情页签条内,在“规则”页签可见;确认删除操作在两种页签下都可用。 diff --git a/packages/client/ui-schedule/src/client/TaskDetail.tsx b/packages/client/ui-schedule/src/client/TaskDetail.tsx index b5e7a77774..ea3cd3d944 100644 --- a/packages/client/ui-schedule/src/client/TaskDetail.tsx +++ b/packages/client/ui-schedule/src/client/TaskDetail.tsx @@ -328,9 +328,10 @@ export function TaskDetail({ const measure = (): void => { const width = linked.getBoundingClientRect().width if (!linkedCompact) linkedExpanded.current = { label: linkedText, width } - const expanded = linkedExpanded.current - // A label changed while compact has no measured expanded width yet. - if (expanded?.label !== linkedText) { + // The first measurement is always expanded, so a width is recorded; a + // label changed while compact has no expanded width for that label yet. + const expanded = linkedExpanded.current as { label: string; width: number } + if (expanded.label !== linkedText) { setLinkedCompact(false) return } @@ -346,6 +347,8 @@ export function TaskDetail({ measure() const observer = new ResizeObserver(measure) observer.observe(tabs) + // A tab label can widen, as when a web font loads, without resizing the list. + for (const tab of tabs.children) observer.observe(tab) return () => { observer.disconnect() } }, [linkedShown, linkedCompact, linkedText]) const taskIdentity = `${task.sessionId}\u0000${task.id}` @@ -728,36 +731,35 @@ export function TaskDetail({ tabs and the task's own actions, so it stays visible while the detail scrolls. Rules-only: the records view has no linked row. A detail hosted inside the linked Session itself omits the entry. */} - {linkedShown && - {linkedCompact - ? - - - : } - } + {linkedCompact + ? <> + + {linkedName} + + : <> + {linkedText} + + {linkedName} + + + } + + + } {/* The menu closes Escape from its own document keydown listener; this wrapper covers a menu whose own listener stands down, where the page's Escape handler would otherwise close the whole detail with it. */} diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css index 74e5164b47..25b573fcd4 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css @@ -1280,6 +1280,16 @@ max-width: 200px; } +/* A disabled entry passes the pointer to this wrapper, whose tooltip names the + compact icon; the wrapper keeps the disabled cursor. */ +.detailContext:has(> button:disabled) { + cursor: not-allowed; +} + +.detailContext > button:disabled { + pointer-events: none; +} + /* The staged-edit bar: it appears under the detail tab strip only while the Run time draft differs from the stored task. */ .saveFooter { diff --git a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx index 499f8209dc..760b6a9eda 100644 --- a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx +++ b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx @@ -3430,19 +3430,36 @@ describe('Task detail rule header and run-time card', () => { return new DOMRect(0, 0, 0, 0) }) try { - const h = mount({ records: [at] }) + let state: WorkspaceSnapshot = workspaces + const h = mount({ records: [at] }, en, { useWorkspaces: select => select(state) }) fireEvent.click(screen.getByRole('button', { name: 'Review release' })) const detail = screen.getByRole('complementary', { name: en['detail.label'] }) const link = (): HTMLElement => within(detailContext(detail)!).getByRole('button', { name: en['detail.openSession'] }) + const tooltip = (): string | null | undefined => document.querySelector('[role="tooltip"]')?.textContent expect(compact()).toBe(true) + // Each tab is observed too, so a tab label that widens re-measures the strip. + for (const tab of within(detail).getAllByRole('tab')) expect(stripObserver().observe).toHaveBeenCalledWith(tab) expect(link().className).toBe(within(detail).getByRole('button', { name: en['detail.more'] }).className) expect(within(link()).queryByText(en['detail.session'])).toBeNull() expect(within(link()).getByText(at.sessionId)).toBeDefined() - fireEvent.mouseEnter(link()) - expect(document.querySelector('[role="tooltip"]')?.textContent).toBe(en['detail.session']) + fireEvent.mouseEnter(detailContext(detail)!) + expect(tooltip()).toBe(en['detail.session']) + fireEvent.mouseLeave(detailContext(detail)!) fireEvent.click(link()) expect(h.props.onOpenSession).toHaveBeenCalledExactlyOnceWith(at.sessionId) + // A disabled compact entry still names itself on hover through its wrapper. + state = { ...workspaces, archivedSessionIds: [at.sessionId] } + h.update({}) + expect(link().hasAttribute('disabled')).toBe(true) + fireEvent.mouseEnter(detailContext(detail)!) + expect(tooltip()).toBe(en['detail.session']) + fireEvent.mouseLeave(detailContext(detail)!) + state = workspaces + h.update({}) + const focused = link() + focused.focus() + // The expanded entry needs 120px beside the tabs' 120px, plus 1px to expand again. strip = 240 act(() => { stripObserver().fire() }) @@ -3450,6 +3467,12 @@ describe('Task detail rule header and run-time card', () => { strip = 241 act(() => { stripObserver().fire() }) expect(compact()).toBe(false) + // The same button changes layout, so keyboard focus survives the switch. + expect(link()).toBe(focused) + expect(document.activeElement).toBe(focused) + fireEvent.mouseEnter(detailContext(detail)!) + expect(tooltip()).toBeUndefined() + fireEvent.mouseLeave(detailContext(detail)!) strip = 240 act(() => { stripObserver().fire() }) expect(compact()).toBe(false)