From a2416dd357d6b35d53d19a2aab5e28b279b56e6e Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 20 Oct 2026 18:24:49 +0800 Subject: [PATCH 01/85] feat(schedule): remove every ended task from the automation page --- apps/web/tests/schedule-after.e2e.ts | 63 +++++++ docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 2 +- docs/persistence-catalog.zh.md | 2 +- docs/subsystems/schedule.i18n.yaml | 4 +- docs/subsystems/schedule.md | 15 +- docs/subsystems/schedule.zh.md | 15 +- packages/client/ui-schedule/README.i18n.yaml | 4 +- packages/client/ui-schedule/README.md | 6 +- packages/client/ui-schedule/README.zh.md | 6 +- .../ui-schedule/src/client/DeleteToast.tsx | 69 ++++--- .../src/client/TaskManagerPage.module.css | 6 +- .../src/client/TaskManagerPage.tsx | 68 ++++++- .../ui-schedule/src/client/clear-ended.ts | 45 +++++ .../client/ui-schedule/src/client/index.ts | 16 ++ .../src/client/task-manager-locales.ts | 21 +++ .../tests/browser-plugin.client.spec.ts | 44 +++++ .../tests/clear-ended.client.spec.ts | 63 +++++++ .../tests/delete-toast.client.spec.tsx | 29 +++ .../tests/task-detail.client.spec.tsx | 1 + .../tests/task-manager-page.client.spec.tsx | 104 ++++++++++- .../expected/radius-exceptions.expected.json | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 10 ++ packages/schedule/schedule/README.i18n.yaml | 4 +- packages/schedule/schedule/README.md | 2 +- packages/schedule/schedule/README.zh.md | 2 +- packages/schedule/schedule/src/index.ts | 43 ++++- packages/schedule/schedule/src/types.ts | 6 + .../schedule/tests/delete-inactive.spec.ts | 170 ++++++++++++++++++ scripts/gen-cordis-catalog.ts | 1 + 32 files changed, 770 insertions(+), 61 deletions(-) create mode 100644 packages/client/ui-schedule/src/client/clear-ended.ts create mode 100644 packages/client/ui-schedule/tests/clear-ended.client.spec.ts create mode 100644 packages/schedule/schedule/tests/delete-inactive.spec.ts diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 89318180b1..d67aa3ab41 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -19,6 +19,7 @@ import type { ContextFormed, GenerateOptions, StreamChunk } from '@deepseek-ai/d import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import { ScheduleId, + createAfterScheduleRecord, createEveryScheduleRecord, foldScheduleEvents, type EveryScheduleRecord, @@ -1258,4 +1259,66 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { await page.clock.setFixedTime(new Date(CATALOG_NOW)) } }, 60_000) + + it('removes every ended task from the page and keeps the active ones', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-clear-ended')) + const endedSession = SessionId('schedule-manager-ended-clear') + const domain = scaffold.ctx.storageDomain.get('schedule') + if (domain === undefined) throw new Error('Schedule domain was not opened') + const endedIds = [ScheduleId('schedule-ended-first'), ScheduleId('schedule-ended-second')] + const endedTitles = ['Ended release check', 'Ended metrics check'] as const + for (const [index, id] of endedIds.entries()) { + const title = endedTitles[index]! + const record = createAfterScheduleRecord(ScheduleId(id), title, 60, Date.now(), title) + const receipt = { + scheduledAt: record.scheduledAt, + deliveredAt: new Date(Date.parse(record.scheduledAt) + 1_000).toISOString(), + messageId: MessageId(`${id}-delivery`), + } + await domain.table('tasks').put(record.id, { + sessionId: endedSession, record, status: 'inactive', lastDelivery: receipt, + deliveryHistory: { records: [receipt], earlierRecordsUnavailable: false }, + }) + } + // A create is the Host's own committed change, so its notification is what + // makes the open page read the rows seeded above. + const kept = await scaffold.ctx.schedule.create(endedSession, { + prompt: 'Keep this running task', title: 'Keep this running task', at: '2099-08-25T12:08:00.000Z', + }) + await page.getByRole('button', { name: 'Automation tasks', exact: true }).click() + const manager = page.getByTestId('task-manager-page') + // Earlier scenarios leave their own search text and status filter on this + // shared page; this scenario asserts the complete list. + await manager.getByRole('searchbox', { name: 'Search tasks' }).fill('') + await manager.getByRole('button', { name: 'All', exact: true }).click() + const clear = manager.getByRole('button', { name: 'Clear ended', exact: true }) + await clear.waitFor({ timeout: 15_000 }) + + // The confirmation states the Host-side target and removes nothing until it + // is confirmed. + await clear.click() + const confirmation = page.getByRole('dialog', { name: 'Clear ended automation tasks?' }) + await confirmation.getByText('2 ended tasks', { exact: true }).waitFor() + await confirmation.getByRole('button', { name: 'Cancel', exact: true }).click() + expect((await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive')).toHaveLength(2) + + await clear.click() + await confirmation.getByRole('button', { name: 'Clear tasks', exact: true }).click() + await expect.poll(async () => (await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive')) + .toEqual([]) + const remaining = await scaffold.ctx.schedule.catalog() + expect(remaining.some(task => endedIds.includes(task.id))).toBe(false) + expect(remaining.map(task => task.title)).toContain('Keep this running task') + await page.getByText('Cleared 2 ended tasks.', { exact: true }).waitFor({ timeout: 15_000 }) + // The page follows the acknowledged removal: no ended row or cleanup action + // remains, and the running task is still listed. + await expect.poll(() => manager.getByRole('button', { name: 'Clear ended', exact: true }).count(), { timeout: 15_000 }) + .toBe(0) + await manager.getByRole('button', { name: 'All', exact: true }).click() + await expect.poll(() => manager.getByRole('list', { name: 'Task catalog' }) + .getByRole('button', { name: kept.title, exact: true }).count(), { timeout: 15_000 }).toBe(1) + expect((await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive')).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + await scaffold.ctx.schedule.delete({ sessionId: endedSession, id: kept.id }) + }, 60_000) }) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 904ebe2ac7..9f7e72ea27 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -62,7 +62,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:405`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:412`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:168`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` | -| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | +| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:484`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | | `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:74`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index a008173af6..05cefc1e33 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -64,7 +64,7 @@ | `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:405`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:412`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:168`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` | -| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | +| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:484`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | | `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:74`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 7aae4e7d05..4e6b8fb553 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -174,8 +174,8 @@ en: e5b55405bd2b69f5 zh: e5b55405bd2b69f5 /session-persistence-event-catalog/events/schedule/schedule-change-log-only: - en: 162584304e006c72 - zh: d8a6bacf60777aa9 + en: e73c202fb99159b6 + zh: b1f43bbd116f83da /session-persistence-event-catalog/events/session: en: a1ac8f2660a4f61e zh: a1ac8f2660a4f61e diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 056aa2e5f9..c4cc4b461f 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -813,7 +813,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s Types: [ScheduleChange](subsystems/schedule.md) -Source: [`packages/schedule/schedule/src/types.ts:365`](../packages/schedule/schedule/src/types.ts) +Source: [`packages/schedule/schedule/src/types.ts:371`](../packages/schedule/schedule/src/types.ts) ### `session/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 71fb808356..c52652cd5f 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -815,7 +815,7 @@ export type SessionEvent = { 类型:[ScheduleChange](subsystems/schedule.zh.md) -来源:[`packages/schedule/schedule/src/types.ts:365`](../packages/schedule/schedule/src/types.ts) +来源:[`packages/schedule/schedule/src/types.ts:371`](../packages/schedule/schedule/src/types.ts) ### `session/*` diff --git a/docs/subsystems/schedule.i18n.yaml b/docs/subsystems/schedule.i18n.yaml index a6e7893cf3..79a3fb77bc 100644 --- a/docs/subsystems/schedule.i18n.yaml +++ b/docs/subsystems/schedule.i18n.yaml @@ -27,8 +27,8 @@ en: 93798ad69f74ddd5 zh: 634126ad8accc356 /host-wide-schedule/web-catalog: - en: b5e5ac8125e65f33 - zh: 9ee064d833a4f838 + en: 630b9879dddd5972 + zh: bfaddb3674ba5c98 /host-wide-schedule/timing-edits: en: ce5d2095a3ad2e82 zh: 167b3715f8eaffc1 diff --git a/docs/subsystems/schedule.md b/docs/subsystems/schedule.md index ef24673f9b..b3f7fa31d2 100644 --- a/docs/subsystems/schedule.md +++ b/docs/subsystems/schedule.md @@ -337,7 +337,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 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. `schedule.deleteInactive()` removes every ended task across Sessions, with the saved delivery records each row carries, and answers `{ deleted }` with the count it removed; the management page's bulk cleanup uses it. An ended task never arms a timer, so the removal recomputes no target. The payload-free `schedule/changed` event invalidates client lists; reconnecting clients fetch current state again. The shipped Web composition 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. @@ -508,6 +508,19 @@ async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: Abor */ @Remote('delete') async delete(request: ScheduleDeleteRequest, signal?: AbortSignal): Promise +/** + * Delete every ended Host task, leaving active tasks armed. + * + * Ended tasks never schedule another delivery, so the removal changes no pending + * target and no timer is recomputed. Rows are enumerated and removed in one FIFO + * slot, so a create ordered behind this call is not removed by it. Each removed row + * is durable on its own: a failure partway through leaves the earlier removals + * committed and announced, then rejects rather than reporting a partial count as + * the whole. + * @returns Count of removed rows; zero when the Host held no ended task. + */ +@Remote('deleteInactive') async deleteInactive(): Promise + /** * Update the name, instruction, and timing of an active task within the original Session * binding without activating the Session or changing saved deliveries. diff --git a/docs/subsystems/schedule.zh.md b/docs/subsystems/schedule.zh.md index 2d140c3821..91e470890a 100644 --- a/docs/subsystems/schedule.zh.md +++ b/docs/subsystems/schedule.zh.md @@ -337,7 +337,7 @@ type ScheduleCatalogEntry = ScheduleRecord & { } ``` -Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。 +Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。`schedule.deleteInactive()` 删除跨会话的全部未运行任务,连同每行携带的已保存投递记录,并以它移除的计数答复 `{ deleted }`;自动化任务页面的批量清理使用该方法。未运行任务从不会安排定时器,因此该删除不重算任何目标。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。 随发行版交付的 Web 组合把 `ui-schedule` 与宿主能力一起挂载。[客户端包](../../packages/client/ui-schedule/README.zh.md) 负责目录、空状态与删除控件。页面单独筛选全部、活动和未运行任务,保留未运行任务详情及详情页签条内的原 Session 入口,并要求显式确认删除。“规则”和“任务运行记录”页签将任务设置与按需分页加载的已保存回执分开。回执与未运行状态均不确认模型执行。 @@ -508,6 +508,19 @@ async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: Abor */ @Remote('delete') async delete(request: ScheduleDeleteRequest, signal?: AbortSignal): Promise +/** + * Delete every ended Host task, leaving active tasks armed. + * + * Ended tasks never schedule another delivery, so the removal changes no pending + * target and no timer is recomputed. Rows are enumerated and removed in one FIFO + * slot, so a create ordered behind this call is not removed by it. Each removed row + * is durable on its own: a failure partway through leaves the earlier removals + * committed and announced, then rejects rather than reporting a partial count as + * the whole. + * @returns Count of removed rows; zero when the Host held no ended task. + */ +@Remote('deleteInactive') async deleteInactive(): Promise + /** * Update the name, instruction, and timing of an active task within the original Session * binding without activating the Session or changing saved deliveries. diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index 8a2a17dc02..cfbb03bf72 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: 7d44cd1640bebfad - zh: 1d6b6c28a3c27115 + en: 9f166d6ea79e3456 + zh: 02e18559999fc1e5 /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 6f02ed050a..67effaa929 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -25,9 +25,9 @@ Manage active and inactive tasks from the Automation tasks page and the right Si ## Use this package -The shipped Web composition mounts `ui-schedule` with the Host Schedule service. The clock context that supplies the model's current time is preset-owned: `standard`, `cordis`, and `ptc` declare it, and `minimal` declares neither it nor the reminder tools. 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 shows a decorative document-and-clock illustration above its notice and New action, with colors that follow the light or dark theme. 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. +The shipped Web composition mounts `ui-schedule` with the Host Schedule service. The clock context that supplies the model's current time is preset-owned: `standard`, `cordis`, and `ptc` declare it, and `minimal` declares neither it nor the reminder tools. 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. Once the catalog holds an ended task, a bulk cleanup action sits left of New and removes every ended task after a confirmation that states how many the Host will remove. 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 shows a decorative document-and-clock illustration above its notice and New action, with colors that follow the light or dark theme. 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. -The page header keeps the title at its natural width and moves New to another line when the two no longer fit together. The action label stays on one line. +The page header keeps the title at its natural width and moves the actions to another line when the title and the actions no longer fit together. Each action label stays on one line. 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. @@ -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, and the header popover — 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. Confirming a deletion closes the detail or the tab it was confirmed from, and 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, and the header popover — 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 ended rows left between the confirmation and the request, and `Could not clear the ended tasks.` after a failure that leaves the remaining ended rows in place to retry. Confirming a deletion closes the detail or the tab it was confirmed from, and 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. diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index b7de6727ed..7e5aa98da1 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -25,9 +25,9 @@ kind: "package-reference" ## 使用本包 -随发行版交付的 Web 组合把 `ui-schedule` 与 Host Schedule 服务一起挂载。为模型提供当前时间的时钟上下文归 preset 所有:`standard`、`cordis` 与 `ptc` 声明它,`minimal` 既不声明它,也不声明提醒工具。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示和“新建”操作上方显示装饰性的文档与时钟插画,配色跟随浅色或深色主题。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。页签条放不下页签列表和带文字的入口时,入口收为与“更多任务操作”同样式的图标按钮,使用会话对话图标,悬停提示“关联会话”,入口禁用时也会显示;切换形态时保留焦点;页签列表可滚动但不显示滚动条。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。 +随发行版交付的 Web 组合把 `ui-schedule` 与 Host Schedule 服务一起挂载。为模型提供当前时间的时钟上下文归 preset 所有:`standard`、`cordis` 与 `ptc` 声明它,`minimal` 既不声明它,也不声明提醒工具。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。目录中存在未运行任务后,“新建”左侧出现批量清理操作,经确认后移除全部未运行任务,确认提示说明 Host 将移除多少个。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示和“新建”操作上方显示装饰性的文档与时钟插画,配色跟随浅色或深色主题。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。页签条放不下页签列表和带文字的入口时,入口收为与“更多任务操作”同样式的图标按钮,使用会话对话图标,悬停提示“关联会话”,入口禁用时也会显示;切换形态时保留焦点;页签列表可滚动但不显示滚动条。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。 -页头按标题的自然宽度排布;标题和“新建”无法并排容纳时,将按钮移到下一行。操作文案保持单行。 +页头按标题的自然宽度排布;标题和操作无法并排容纳时,将操作移到下一行。每个操作文案保持单行。 从列表选择任务时默认打开“规则”;刷新同一任务会保留当前页签。活动任务的“规则”修改名称、指令和运行时间;这些修改保留在本地草稿中直到保存,未运行任务保持只读。不带修饰键的左右方向键循环切换页签,Home/End 选择第一个/最后一个页签,键盘焦点随选择移动。页签不会拦截这些键与 Alt、Ctrl、Meta 或 Shift 的组合。共用的会话入口位于详情页签条内,在“规则”页签可见;确认删除操作在两种页签下都可用。 @@ -41,7 +41,7 @@ kind: "package-reference" 历史查询成功的空页与加载中、查询失败、任务不存在或游标无效相互区分。请求失败时保留此前加载的记录,并显示警告和“重试加载任务运行记录”;游标无效时提供“刷新任务运行记录”,重新加载最新一页。最新 `messageId` 变化时刷新最新一页,不切换页签。被刷新取代的响应,以及切换任务、离开任务运行记录或关闭详情后才收到的响应,不会替换当前视图。 -自动化任务页面要求确认后才能删除任务。删除是硬删除:Host 连同已保存的任务运行记录一起移除存储的任务行,该任务因此停止后续投递、离开 `list` 与 `catalog`,其投递历史不再可读。删除保留原会话和已入队消息;列表仅在权威查询确认移除之前保留该行。每条删除路径——自动化任务页的详情、会话的任务页签、以及页头弹层——都把最终结果写入同一个存储,由应用级 `shell.overlay` 提示渲染,因此提示的生命周期长于发起它的面板或页签:Host 连同已保存的运行记录移除该行后提示显示「任务已删除」,失败时显示「无法删除任务」,并保留规则以便重试。确认删除会关闭确认所在的面板或页签,每条提示按自身序号区分,后一条会重新开始横幅而不是并入前一条。查询失败保留最近已知记录,并显示独立的重试操作;不会呈现为查询成功的空结果。一次性任务在持久收件箱投递后仍以未运行状态保存。显式删除前,通过全部或未运行筛选仍可查看详情与原会话 id。最近一次投递显示其发生时间,不显示消息 id 和投递确认时间;未运行状态和该回执均不表示模型执行完成。 +自动化任务页面要求确认后才能删除任务。删除是硬删除:Host 连同已保存的任务运行记录一起移除存储的任务行,该任务因此停止后续投递、离开 `list` 与 `catalog`,其投递历史不再可读。删除保留原会话和已入队消息;列表仅在权威查询确认移除之前保留该行。每条删除路径——自动化任务页的详情、会话的任务页签、以及页头弹层——都把最终结果写入同一个存储,由应用级 `shell.overlay` 提示渲染,因此提示的生命周期长于发起它的面板或页签:Host 连同已保存的运行记录移除该行后提示显示「任务已删除」,失败时显示「无法删除任务」,并保留规则以便重试。批量清理也由该提示报告:显示 Host 答复的行数「已清理 N 个已结束任务」;确认到请求之间未运行行已消失时显示「没有可清理的已结束任务」;失败时显示「无法清理已结束任务」,并保留尚未移除的未运行行以便重试。确认删除会关闭确认所在的面板或页签,每条提示按自身序号区分,后一条会重新开始横幅而不是并入前一条。查询失败保留最近已知记录,并显示独立的重试操作;不会呈现为查询成功的空结果。一次性任务在持久收件箱投递后仍以未运行状态保存。显式删除前,通过全部或未运行筛选仍可查看详情与原会话 id。最近一次投递显示其发生时间,不显示消息 id 和投递确认时间;未运行状态和该回执均不表示模型执行完成。 已打开的会话只有在确实有可打开的内容时,才在页头右侧功能位中、紧邻更多操作菜单左侧渲染一个仅图标时钟按钮:首次读取前不渲染任何内容,读取结果为没有活动提醒时页头不显示该按钮,刷新期间保留上一次已知的提醒,读取失败则保留按钮及其重试。该规则避免时钟在首次结果前后出现又消失。其无障碍名称在读取就绪时给出提醒数量,否则显示通用提醒标签。当该会话只有一个活动提醒时,按钮直接在右侧栏打开该任务的详情。其余数量都会打开弹层:先列出逾期提醒,再按目标时间列出未来提醒,显示已存储的标题、频率、浏览器本地目标时间、相对时间及删除按钮;标题本身是一个按钮,点击后在右侧栏打开该任务的详情。任务页签在标签上显示当前任务已存储的标题,并提供与自动化任务页面相同的规则、任务运行记录、名称、指令和运行时间修改、确认删除和原会话入口;它不会激活会话。每日频率文本保留保存的本地时间和时区;每个下一目标时点都显示为设备时区的钟点和括号内的相对时长;规则自身的时区只显示在该频率文本中。Escape 关闭弹层并将焦点返回按钮;点击外部将其关闭。删除最后一条提醒会移除按钮;在此之前,已经打开的弹层保持可展开状态。 diff --git a/packages/client/ui-schedule/src/client/DeleteToast.tsx b/packages/client/ui-schedule/src/client/DeleteToast.tsx index af3da62aff..556f5a02bf 100644 --- a/packages/client/ui-schedule/src/client/DeleteToast.tsx +++ b/packages/client/ui-schedule/src/client/DeleteToast.tsx @@ -1,23 +1,26 @@ /** * Transient outcome notice for schedule deletions. * - * Every deletion — the Tasks page detail, the session task tab, and the - * Session-header popover — reports its settled outcome into one store, and one - * `shell.overlay` entry renders it as the app-wide banner. The surfaces keep no - * in-place deletion notice of their own, so an outcome outlives the panel or - * tab that asked for it. + * Every deletion — the Tasks page detail, the session task tab, the + * Session-header popover, and the Tasks page's removal of every ended task — + * reports its settled outcome into one store, and one `shell.overlay` entry + * renders it as the app-wide banner. The surfaces keep no in-place deletion + * notice of their own, so an outcome outlives the panel or tab that asked for + * it. */ import type { ReactNode } from 'react' import { IconWarningOutlineRegular, Toast } from '@deepseek-ai/dsh-client-ui-primitives' import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import { assertNever } from '@deepseek-ai/dsh-util-values' import type { CatalogDeleteOutcome } from './catalog-source.ts' +import type { ClearEndedOutcome } from './clear-ended.ts' -/** One reported deletion outcome; `seq` keys the banner so a re-show restarts it. */ -export interface DeleteToastState { - readonly kind: 'deleted' | 'deleteFailed' - readonly seq: number -} +/** One reported outcome; `seq` keys the banner so a re-show restarts it. */ +export type DeleteToastState = + | { readonly kind: 'deleted' | 'deleteFailed'; readonly seq: number } + | { readonly kind: 'cleared'; readonly count: number; readonly seq: number } + | { readonly kind: 'clearFailed'; readonly seq: number } /** Store the deletion wiring reports into and the overlay entry reads. */ export interface DeleteToastSource { @@ -29,6 +32,11 @@ export interface DeleteToastSource { * @param outcome - resolution of `CatalogInjected.onDelete`. */ readonly report: (outcome: CatalogDeleteOutcome) => void + /** + * Show the notice of one settled bulk removal. + * @param outcome - resolution of the management page's bulk removal callback. + */ + readonly reportClear: (outcome: ClearEndedOutcome) => void /** Clear the shown notice. */ readonly dismiss: () => void } @@ -59,6 +67,11 @@ export function createDeleteToastSource(): DeleteToastSource { if (outcome === 'pending') return publish({ kind: outcome === 'deleted' ? 'deleted' : 'deleteFailed', seq: ++seq }) }, + reportClear: (outcome) => { + publish(outcome.cleared + ? { kind: 'cleared', count: outcome.count, seq: ++seq } + : { kind: 'clearFailed', seq: ++seq }) + }, dismiss: () => { publish(null) }, } } @@ -66,23 +79,37 @@ export function createDeleteToastSource(): DeleteToastSource { /** Props of the `shell.overlay` entry: the notice store and localized copy. */ export type ScheduleDeleteToastProps = PropsRuntime<'shell.overlay'> & PropsLocale<'schedule.manager'> - & InjectFace> + & InjectFace> /** - * Render the current deletion notice: a success banner for a confirmed - * deletion, a warning for one that could not be confirmed, or nothing. + * Render the current deletion notice: a success banner for a confirmed deletion + * or settled bulk removal, a warning for one that could not be confirmed, or + * nothing. * @param props - the notice hook, its dismissal, and the locale seat. * @returns the banner on display, or null. */ export function ScheduleDeleteToast({ useToast, dismiss, t }: ScheduleDeleteToastProps): ReactNode { const toast = useToast(current => current) if (toast === null) return null - return toast.kind === 'deleted' - ? - : } - onDone={dismiss} - /> + const key = `schedule-delete-${String(toast.seq)}` + switch (toast.kind) { + case 'deleted': + return + case 'deleteFailed': + return } 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 + case 'clearFailed': + return } onDone={dismiss} /> + /* v8 ignore next -- the store publishes only the union's members, so the default holds no reachable statement. */ + default: + return assertNever(toast) + } } diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css index ebdb77b4b8..c76a57bc51 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css @@ -87,8 +87,10 @@ white-space: nowrap; } -/* The Plugins toolbar's Add-plugin step: a 32px capsule with the 13px label. */ -.newButton { +/* The Plugins toolbar's steps: 32px capsules with the 13px label. The New + action and the bulk cleanup share the geometry, so the pair reads as one + toolbar. */ +.headerAction { height: 32px; font-size: 13px; line-height: 20px; diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx index 7d18063ae8..841b559cf8 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' import { - Button, IconClockOutlineRegular, IconCloseOutlineRegular, IconPlusOutlineRegular, IconSearchOutlineRegular, Input, + Button, IconClockOutlineRegular, IconCloseOutlineRegular, IconPlusOutlineRegular, IconSearchOutlineRegular, Input, Modal, } from '@deepseek-ai/dsh-client-ui-primitives' import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' @@ -25,6 +25,16 @@ export interface TaskManagerInjected extends TaskDetailInjected { * schedule it. The page deliberately has no creation form of its own. */ readonly onNewTask: () => void + /** + * Remove every ended Host task, after this page has confirmed the action. + * + * The call requests no task ids, so the Host's ended set is the one removed; + * the page's rows are only a snapshot that a delivery can have moved. It + * resolves once the settled outcome has been reported through the app-wide + * notice, which is the only place the result is stated. + * @returns resolution after the outcome is reported. + */ + readonly onClearEnded: () => Promise } /** Root-scoped task catalog props derived from the framework and injected actions. */ @@ -40,12 +50,14 @@ type StatusFilter = 'all' | ScheduleCatalogEntry['status'] * @returns the searchable task list beside the selected task's detail. */ export function TaskManagerPage(props: TaskManagerPageProps) { - const { useCatalog, onNewTask, onRetry, t } = props + const { useCatalog, onNewTask, onClearEnded, onRetry, t } = props const catalog = useCatalog(snapshot => snapshot) const { records, status } = catalog const [search, setSearch] = useState('') const [statusFilter, setStatusFilter] = useState('all') const [selectedId, setSelectedId] = useState(null) + const [clearConfirm, setClearConfirm] = useState(false) + const [clearing, setClearing] = useState(false) // The rows state how long remains, so they read the shared ticking clock // rather than a value sampled at mount: a catalog refresh can move the target // this text describes. @@ -55,6 +67,12 @@ export function TaskManagerPage(props: TaskManagerPageProps) { const rowRef = useRef(null) const headingRef = useRef(null) 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. + const endedCount = useMemo( + () => records.filter(record => record.status === 'inactive').length, + [records], + ) const rows = useMemo(() => { const query = search.trim().toLowerCase() return records.filter(record => ( @@ -97,16 +115,25 @@ export function TaskManagerPage(props: TaskManagerPageProps) { setSelectedId(null) } + const confirmClearEnded = (): void => { + setClearing(true) + void onClearEnded().finally(() => { + setClearing(false) + setClearConfirm(false) + }) + } + return (
{ - // A dropdown that closed on this Escape has already consumed the key: the - // Menu primitive closes from the document capture phase and calls - // preventDefault there, before React dispatches this handler. - if (event.key !== 'Escape' || event.defaultPrevented || confirmId !== null || selectedId === null) return + // 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. + if (event.key !== 'Escape' || event.defaultPrevented || confirmId !== null || clearConfirm + || selectedId === null) return event.preventDefault() event.stopPropagation() closeDetails() @@ -118,7 +145,13 @@ export function TaskManagerPage(props: TaskManagerPageProps) {

{t('title')}

- + {endedCount > 0 && } +
@@ -209,6 +242,27 @@ export function TaskManagerPage(props: TaskManagerPageProps) { onDeleted={closeDetails} onClose={closeDetails} />} + { if (!clearing) setClearConfirm(false) }} + footer={
+ + +
} + > +

{t('clear.count', { count: endedCount })}

+
) } diff --git a/packages/client/ui-schedule/src/client/clear-ended.ts b/packages/client/ui-schedule/src/client/clear-ended.ts new file mode 100644 index 0000000000..99a42f7ad2 --- /dev/null +++ b/packages/client/ui-schedule/src/client/clear-ended.ts @@ -0,0 +1,45 @@ +/** Host-wide removal of every ended task, as the management page's one action reports it. */ +import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' +import type { ScheduleDeleteInactiveResult } from '@deepseek-ai/dsh-schedule/client' + +/** How one bulk removal settled; the wiring reports it as an app-wide notice. */ +export type ClearEndedOutcome = + | { readonly cleared: true; readonly count: number } + | { readonly cleared: false } + +/** Remote removal and the authoritative readback that follows it. */ +export interface ClearEndedDependencies { + /** Ask the Host to remove every ended task; it answers how many rows it removed. */ + readonly removeInactive: () => Promise> + /** + * Read the catalog again, naming the newest request ordinal so a read sent + * before the removal is superseded rather than reused. + * @returns resolution after the read result is published. + */ + readonly refresh: () => Promise +} + +/** + * Create the management page's bulk removal callback. + * + * 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. + * @param deps - Remote removal and its catalog readback. + * @returns a callback that resolves once the settled outcome is available. + */ +export function createClearEnded(deps: ClearEndedDependencies): () => Promise { + return async () => { + let result: RemoteResult + try { + result = await deps.removeInactive() + } catch (_error: unknown) { + // A rejected request does not establish which rows changed. + return { cleared: false } + } + if (!result.ok) return { cleared: false } + await deps.refresh() + return { cleared: true, count: result.value.deleted } + } +} diff --git a/packages/client/ui-schedule/src/client/index.ts b/packages/client/ui-schedule/src/client/index.ts index 084d0edb8d..c203ce42e2 100644 --- a/packages/client/ui-schedule/src/client/index.ts +++ b/packages/client/ui-schedule/src/client/index.ts @@ -43,6 +43,7 @@ import type {} from '@deepseek-ai/dsh-client-connection/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ScheduleCatalogEntry, ScheduleDeliveryHistoryRequest, ScheduleId, ScheduleUpdateRequest } from '@deepseek-ai/dsh-schedule/client' import { createCatalogSource, type CatalogDeleteOutcome, type CatalogInjected } from './catalog-source.ts' +import { createClearEnded } from './clear-ended.ts' import { createDeleteToastSource, ScheduleDeleteToast } from './DeleteToast.tsx' import { SCHEDULE_TASK_ID, SCHEDULE_TASK_KIND, scheduleTaskDefinition } from './definition.ts' import { ScheduleCatalogAction } from './ScheduleCatalogAction.tsx' @@ -137,6 +138,17 @@ export function apply(ctx: ClientContext): void { ctx.uiWorkspace.openSession(id) } } + /** + * Remove every ended Host task and read the catalog back. + * + * The readback names every request seen so far, so it supersedes a read sent + * before the removal instead of joining it. + * @returns the settled bulk-removal outcome. + */ + const clearEnded = createClearEnded({ + removeInactive: () => ctx.remote.schedule.deleteInactive(), + refresh: () => manager.onRetry(manager.hooks.catalog.getSnapshot().readRequest), + }) const detail: TaskDetailInjected & ScheduleTaskCatalogInjected = { hooks: manager.hooks, onDelete: reportedDelete(manager.onDelete), @@ -173,6 +185,10 @@ export function apply(ctx: ClientContext): void { ...detail, // The page has no creation form: a new reminder starts in a Session. onNewTask: () => { ctx.uiWorkspace.startSession() }, + onClearEnded: async () => { + const outcome = await clearEnded() + deleteToast.reportClear(outcome) + }, }), }, TaskManagerPage)) ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ 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 7439d9733b..056f8a3cbb 100644 --- a/packages/client/ui-schedule/src/client/task-manager-locales.ts +++ b/packages/client/ui-schedule/src/client/task-manager-locales.ts @@ -134,8 +134,19 @@ export const en = { 'delete.cancel': 'Cancel', 'delete.close': 'Close deletion confirmation', 'delete.pending': 'Deleting…', + 'clear.action': 'Clear ended', + 'clear.title': 'Clear ended automation tasks?', + 'clear.description': 'These tasks stop triggering and are deleted together with their saved delivery records. ' + + 'The original sessions and their messages remain.', + 'clear.count': '{count} ended tasks', + 'clear.confirm': 'Clear tasks', + 'clear.pending': 'Clearing…', + 'clear.close': 'Close clear confirmation', 'toast.deleted': 'Task deleted.', 'toast.deleteFailed': 'Could not delete the task.', + 'toast.cleared': 'Cleared {count} ended tasks.', + 'toast.clearedNone': 'No ended tasks to clear.', + 'toast.clearFailed': 'Could not clear the ended tasks.', 'card.open': 'Open', 'card.openLabel': 'Open task details: {title}', 'card.deleted': 'Deleted', @@ -276,8 +287,18 @@ export const zh: Record = { 'delete.cancel': '取消', 'delete.close': '关闭删除确认', 'delete.pending': '正在删除…', + 'clear.action': '清理已结束', + 'clear.title': '清理已结束的自动化任务?', + 'clear.description': '这些任务将停止触发,并连同其保存的运行记录一并删除。原会话及其消息仍然保留', + 'clear.count': '共 {count} 个已结束任务', + 'clear.confirm': '确认清理', + 'clear.pending': '正在清理…', + 'clear.close': '关闭清理确认', 'toast.deleted': '任务已删除', 'toast.deleteFailed': '无法删除任务', + 'toast.cleared': '已清理 {count} 个已结束任务', + 'toast.clearedNone': '没有可清理的已结束任务', + 'toast.clearFailed': '无法清理已结束任务', 'card.open': '打开', 'card.openLabel': '打开任务详情:{title}', 'card.deleted': '已删除', diff --git a/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts b/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts index 4fa73699b9..75b8f0c32d 100644 --- a/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts @@ -475,6 +475,50 @@ describe('ui-schedule browser half', () => { } }) + it('clears every ended task from the global page, reads the catalog back, and reports the count', async () => { + const record: ScheduleCatalogEntry = { + id: 'host-ended' as ScheduleId, sessionId: 'cold-original' as SessionId, status: 'inactive', + kind: 'after', title: 'Ended review', prompt: 'Ended review', afterSeconds: 60, + scheduledAt: '2099-01-01T00:00:00.000Z', + } + const catalog = vi.fn() + .mockResolvedValueOnce({ ok: true, value: [record] }) + .mockResolvedValue({ ok: true, value: [] }) + const deleteInactive = vi.fn().mockResolvedValue({ ok: true, value: { deleted: 1 } }) + 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() + 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 + const dispose = face.hooks.catalog.subscribe(vi.fn()) + try { + await vi.waitFor(() => { expect(face.hooks.catalog.getSnapshot().records).toEqual([record]) }) + const reads = catalog.mock.calls.length + await face.onClearEnded() + expect(deleteInactive).toHaveBeenCalledOnce() + // The acknowledged removal reads the catalog back instead of joining a + // read issued before it, so the page's rows follow the removal. + expect(catalog.mock.calls.length).toBe(reads + 1) + await vi.waitFor(() => { expect(face.hooks.catalog.getSnapshot().records).toEqual([]) }) + expect(toast.hooks.toast.getSnapshot()).toEqual({ kind: 'cleared', count: 1, seq: 1 }) + } finally { + dispose() + } + } finally { + await fiber.dispose() + owner() + 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: () => () => {} }) diff --git a/packages/client/ui-schedule/tests/clear-ended.client.spec.ts b/packages/client/ui-schedule/tests/clear-ended.client.spec.ts new file mode 100644 index 0000000000..d453deef50 --- /dev/null +++ b/packages/client/ui-schedule/tests/clear-ended.client.spec.ts @@ -0,0 +1,63 @@ +/** The management page's bulk removal: one request, one readback, one settled outcome. */ +import { describe, expect, it, vi } from 'vitest' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' +import type { ScheduleDeleteInactiveResult } from '@deepseek-ai/dsh-schedule/client' +import { createClearEnded } from '../src/client/clear-ended.ts' + +const failure: RemoteResult = { + ok: false, error: new RemoteError('gateway/internal', 'Unavailable', {}), +} + +/** + * Wire the helper to one fake Remote answer and one readback spy. + * @param answer - what the removal call answers, or the reason it rejects. + * @param refresh - the readback the helper calls after an acknowledged removal. + * @returns the callback under test with its dependencies' spies. + */ +function setup( + answer: RemoteResult | Error, + refresh = vi.fn(async () => {}), +) { + const removeInactive = vi.fn(async () => { + if (answer instanceof Error) throw answer + return answer + }) + return { clearEnded: createClearEnded({ removeInactive, refresh }), removeInactive, refresh } +} + +describe('createClearEnded', () => { + it('reads the catalog back after an acknowledged removal and reports how many rows went', async () => { + const { clearEnded, removeInactive, refresh } = setup({ ok: true, value: { deleted: 3 } }) + + await expect(clearEnded()).resolves.toEqual({ cleared: true, count: 3 }) + + expect(removeInactive).toHaveBeenCalledOnce() + expect(refresh).toHaveBeenCalledOnce() + // The readback is what makes the list agree with the count, so it settles + // before the outcome is published. + expect(removeInactive.mock.invocationCallOrder[0]) + .toBeLessThan(refresh.mock.invocationCallOrder[0]!) + }) + + it('reports an acknowledged removal that found nothing as a cleared outcome', async () => { + const { clearEnded, refresh } = setup({ ok: true, value: { deleted: 0 } }) + + await expect(clearEnded()).resolves.toEqual({ cleared: true, count: 0 }) + expect(refresh).toHaveBeenCalledOnce() + }) + + it('reports a refused removal and reads nothing back', async () => { + const { clearEnded, refresh } = setup(failure) + + await expect(clearEnded()).resolves.toEqual({ cleared: false }) + expect(refresh).not.toHaveBeenCalled() + }) + + it('reports a rejected removal and reads nothing back', async () => { + const { clearEnded, refresh } = setup(new Error('connection lost')) + + await expect(clearEnded()).resolves.toEqual({ cleared: false }) + expect(refresh).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/ui-schedule/tests/delete-toast.client.spec.tsx b/packages/client/ui-schedule/tests/delete-toast.client.spec.tsx index 55f6535587..aadce87d0c 100644 --- a/packages/client/ui-schedule/tests/delete-toast.client.spec.tsx +++ b/packages/client/ui-schedule/tests/delete-toast.client.spec.tsx @@ -48,6 +48,17 @@ describe('delete toast source', () => { source.report('deleted') expect(listener).toHaveBeenCalledTimes(4) }) + + it('publishes each settled bulk removal with its count or failure', () => { + const source = createDeleteToastSource() + source.reportClear({ cleared: true, count: 3 }) + expect(source.hooks.toast.getSnapshot()).toEqual({ kind: 'cleared', count: 3, seq: 1 }) + // A removal that found nothing keeps its own zero, so the banner can say so. + source.reportClear({ cleared: true, count: 0 }) + expect(source.hooks.toast.getSnapshot()).toEqual({ kind: 'cleared', count: 0, seq: 2 }) + source.reportClear({ cleared: false }) + expect(source.hooks.toast.getSnapshot()).toEqual({ kind: 'clearFailed', seq: 3 }) + }) }) describe('ScheduleDeleteToast', () => { @@ -68,4 +79,22 @@ describe('ScheduleDeleteToast', () => { mount({ kind: 'deleteFailed', seq: 2 }, zh) expect(screen.getByRole('alert').textContent).toBe(zh['toast.deleteFailed']) }) + + it('states how many ended tasks a bulk removal cleared', () => { + vi.useFakeTimers() + const { dismiss } = mount({ kind: 'cleared', count: 4, seq: 3 }) + expect(screen.getByRole('alert').textContent).toBe(en['toast.cleared'].replace('{count}', '4')) + act(() => { vi.runAllTimers() }) + expect(dismiss).toHaveBeenCalledOnce() + }) + + it('states that a bulk removal found nothing instead of a zero count', () => { + mount({ kind: 'cleared', count: 0, seq: 4 }, zh) + expect(screen.getByRole('alert').textContent).toBe(zh['toast.clearedNone']) + }) + + it('shows the warning banner for a bulk removal that failed', () => { + mount({ kind: 'clearFailed', seq: 5 }, zh) + expect(screen.getByRole('alert').textContent).toBe(zh['toast.clearFailed']) + }) }) diff --git a/packages/client/ui-schedule/tests/task-detail.client.spec.tsx b/packages/client/ui-schedule/tests/task-detail.client.spec.tsx index ff64d41b88..97bef63a33 100644 --- a/packages/client/ui-schedule/tests/task-detail.client.spec.tsx +++ b/packages/client/ui-schedule/tests/task-detail.client.spec.tsx @@ -122,6 +122,7 @@ function mount( onDelete: vi.fn(async () => 'deleted'), onRetry: vi.fn(async () => {}), onNewTask: vi.fn(), + onClearEnded: vi.fn(async () => {}), onUpdateTiming: vi.fn(async ({ expected }) => ({ ok: true, value: { id: expected.id, updated: false, record: expected }, })), 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 a7892edc19..dad39c52ba 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 @@ -178,6 +178,7 @@ function mount( onDelete: vi.fn(async () => 'deleted'), onRetry: vi.fn(async () => {}), onNewTask: vi.fn(), + onClearEnded: vi.fn(async () => {}), onUpdateTiming: vi.fn(async ({ expected }) => ({ ok: true, value: { id: expected.id, updated: false, record: expected }, })), @@ -942,6 +943,98 @@ describe('Task manager catalog', () => { }) }) +describe('clearing ended tasks', () => { + // A distinct identity: the module's `ended` case reuses the active row's id. + const endedTask: ScheduleCatalogEntry = { + ...ended, id: 'task-ended' as ScheduleId, title: 'Ended summary', prompt: 'Ended summary', + } + const anotherEnded: ScheduleCatalogEntry = { + ...ended, id: 'task-ended-two' as ScheduleId, title: 'Ended report', prompt: 'Ended report', + } + + /** The heading's action pair, left to right. */ + function creationActions(): HTMLElement { + return document.querySelector(`.${css.creationActions!}`)! + } + + it('offers the cleanup beside New only while the catalog holds an ended task', () => { + const h = mount() + expect(within(creationActions()).queryByRole('button', { name: en['clear.action'] })).toBeNull() + h.update({ records: [...records, endedTask] }) + // The cleanup sits left of the primary action, which keeps the row's end. + expect(within(creationActions()).getAllByRole('button').map(button => button.textContent)) + .toEqual([en['clear.action'], en['new.action']]) + // An ended task that the current view filters out still offers the cleanup. + fireEvent.click(screen.getByRole('button', { name: en['status.active'] })) + expect(screen.queryByRole('button', { name: 'Ended summary' })).toBeNull() + expect(within(creationActions()).getByRole('button', { name: en['clear.action'] })).toBeDefined() + }) + + it('states the complete ended set in the confirmation, not the filtered rows', () => { + mount({ records: [...records, endedTask, anotherEnded] }) + fireEvent.change(screen.getByRole('searchbox', { name: en['search.label'] }), { + target: { value: 'Send summary' }, + }) + expect(taskNames()).toEqual(['Send summary']) + fireEvent.click(screen.getByRole('button', { name: en['clear.action'] })) + const dialog = screen.getByRole('dialog', { name: en['clear.title'] }) + expect(within(dialog).getByText(en['clear.description'])).toBeDefined() + expect(within(dialog).getByText(en['clear.count'].replace('{count}', '2'))).toBeDefined() + }) + + it('removes nothing when the confirmation is dismissed', () => { + const h = mount({ records: [at, endedTask] }) + fireEvent.click(screen.getByRole('button', { name: en['clear.action'] })) + fireEvent.click(screen.getByRole('button', { name: en['delete.cancel'] })) + expect(screen.queryByRole('dialog', { name: en['clear.title'] })).toBeNull() + expect(h.props.onClearEnded).not.toHaveBeenCalled() + }) + + it('keeps the confirmation open until the settled removal, then returns focus', async () => { + const pending = Promise.withResolvers() + const onClearEnded = vi.fn(() => pending.promise) + mount({ records: [at, endedTask] }, en, { onClearEnded }) + const trigger = screen.getByRole('button', { name: en['clear.action'] }) + // A pointer press focuses the control in the browser; jsdom leaves focus alone. + trigger.focus() + fireEvent.click(trigger) + const dialog = screen.getByRole('dialog', { name: en['clear.title'] }) + fireEvent.click(within(dialog).getByRole('button', { name: en['clear.confirm'] })) + expect(onClearEnded).toHaveBeenCalledOnce() + + const running = screen.getByRole('dialog', { name: en['clear.title'] }) + expect(within(running).getByRole('button', { name: en['clear.pending'] }).hasAttribute('disabled')).toBe(true) + expect(within(running).getByRole('button', { name: en['delete.cancel'] }).hasAttribute('disabled')).toBe(true) + // The confirmation is the only place that states the removal still runs, so + // the Escape that would close it waits for the outcome. + fireEvent.keyDown(running, { key: 'Escape' }) + expect(screen.getByRole('dialog', { name: en['clear.title'] })).toBe(running) + + await act(async () => { pending.resolve(undefined) }) + expect(screen.queryByRole('dialog', { name: en['clear.title'] })).toBeNull() + expect(document.activeElement).toBe(trigger) + }) + + 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' })) + const detail = screen.getByRole('complementary', { name: en['detail.label'] }) + fireEvent.click(screen.getByRole('button', { name: en['clear.action'] })) + const dialog = screen.getByRole('dialog', { name: en['clear.title'] }) + fireEvent.keyDown(dialog, { key: 'Escape' }) + expect(screen.queryByRole('dialog', { name: en['clear.title'] })).toBeNull() + expect(screen.getByRole('complementary', { name: en['detail.label'] })).toBe(detail) + }) + + it('states the cleanup and its confirmation in Chinese', () => { + mount({ records: [at, endedTask] }, zh) + fireEvent.click(screen.getByRole('button', { name: zh['clear.action'] })) + const dialog = screen.getByRole('dialog', { name: zh['clear.title'] }) + expect(within(dialog).getByText(zh['clear.count'].replace('{count}', '1'))).toBeDefined() + expect(within(dialog).getByRole('button', { name: zh['clear.confirm'] })).toBeDefined() + }) +}) + describe.each([['English', en], ['Chinese', zh]] as const)('task detail views in %s', (_name, dictionary) => { it('opens Rules under the selected task and separates rule fields from saved deliveries', async () => { const labels = detailViewLabels(dictionary) @@ -3814,11 +3907,12 @@ describe('Mock detail metrics shared with the task list', () => { ) // The Plugins toolbar's Add-plugin capsule, repeated by the creation action. expect(rule('creationActions')).toMatch(/gap:\s*16px;/) - const newButton = rule('newButton') - expect(newButton).toMatch(/height:\s*32px;/) - expect(newButton).toMatch(/font-size:\s*13px;/) - expect(newButton).toMatch(/padding:\s*0 12px;/) - expect(newButton).toMatch(/border-radius:\s*16px;/) + // The New action and the bulk cleanup share this capsule geometry. + const headerAction = rule('headerAction') + expect(headerAction).toMatch(/height:\s*32px;/) + expect(headerAction).toMatch(/font-size:\s*13px;/) + expect(headerAction).toMatch(/padding:\s*0 12px;/) + expect(headerAction).toMatch(/border-radius:\s*16px;/) const headingTitle = /\.pageHeading h1\s*\{([^}]*)\}/.exec(stylesheet)?.[1] ?? '' // The first-level title step is the Plugins page's `pageTitle`, not the // mock's larger page heading; the equality case below pins that pair. diff --git a/packages/client/ui-theme/tests/expected/radius-exceptions.expected.json b/packages/client/ui-theme/tests/expected/radius-exceptions.expected.json index 274fc5b552..aee0023246 100644 --- a/packages/client/ui-theme/tests/expected/radius-exceptions.expected.json +++ b/packages/client/ui-theme/tests/expected/radius-exceptions.expected.json @@ -29,7 +29,7 @@ ".leading: border-radius: 10px" ], "ui-schedule/src/client/TaskManagerPage.module.css": [ - ".newButton: border-radius: 16px", + ".headerAction: border-radius: 16px", ".filterTab: border-radius: 14px", ".searchField: border-radius: 12px", ".row: border-radius: 12px", diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index be518c2952..c14c966f8d 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2200,6 +2200,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'request', description: 'Session and exact task identity.' }, { name: 'signal', description: 'Optional cancellation checked before persistence begins, including after FIFO waits.' }], returns: 'Whether that Session owned a deleted task. Cancellation does not roll back an in-flight write.', }, + { + signature: '@Remote(\'deleteInactive\') async deleteInactive(): Promise', + description: 'Delete every ended Host task, leaving active tasks armed.\n\nEnded tasks never schedule another delivery, so the removal changes no pending target and no timer is recomputed. Rows are enumerated and removed in one FIFO slot, so a create ordered behind this call is not removed by it. Each removed row is durable on its own: a failure partway through leaves the earlier removals committed and announced, then rejects rather than reporting a partial count as the whole.', + parameters: [], + returns: 'Count of removed rows; zero when the Host held no ended task.', + }, { signature: '@Remote(\'update\') async update(request: ScheduleUpdateRequest, signal?: AbortSignal): Promise', description: 'Update the name, instruction, and timing of an active task within the original Session binding without activating the Session or changing saved deliveries.\n\nEach supplied field replaces its stored value; an omitted field keeps it. A name or instruction change alone does not reset the committed target. A Session a delegated child owns returns the non-mutating `subagent_session` result, so an edit cannot re-arm a task bound to a Session delivery can never reach, and the Web editor can explain the refusal through the ordinary result it already renders.', @@ -7156,6 +7162,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ScheduleCreateRequest', declaration: 'export interface ScheduleCreateRequest {\n prompt: string;\n title: string;\n after_seconds?: number;\n at?: AtInput;\n every_seconds?: number;\n daily?: DailyInput;\n weekly?: WeeklyInput;\n cron?: CronInput;\n}', }, + { + name: 'ScheduleDeleteInactiveResult', + declaration: 'export interface ScheduleDeleteInactiveResult {\n readonly deleted: number;\n}', + }, { name: 'ScheduleDeleteRequest', declaration: 'export interface ScheduleDeleteRequest extends ScheduleListRequest {\n id: ScheduleId;\n}', diff --git a/packages/schedule/schedule/README.i18n.yaml b/packages/schedule/schedule/README.i18n.yaml index 8e4f6e7355..373a697408 100644 --- a/packages/schedule/schedule/README.i18n.yaml +++ b/packages/schedule/schedule/README.i18n.yaml @@ -15,8 +15,8 @@ en: 23386452a66b2fbd zh: 4a8d91b2f313fead /deepseek-ai-dsh-schedule/use-this-package: - en: ec60f8eec2f735bc - zh: ed06b44badc93456 + en: 694ff524482a3ac0 + zh: 139e1814a270421c /deepseek-ai-dsh-schedule/understand-the-implementation: en: 02848e6fd6317b2a zh: 1807a423210f81ef diff --git a/packages/schedule/schedule/README.md b/packages/schedule/schedule/README.md index a14c95023e..0fe1d65bb7 100644 --- a/packages/schedule/schedule/README.md +++ b/packages/schedule/schedule/README.md @@ -44,7 +44,7 @@ Weekly input adds `weekdays`, a non-empty set of ISO weekday numbers from Monday Cron input carries `expression` and `time_zone`. The expression is the standard five-field Vixie form `minute hour day-of-month month day-of-week`: minute 0-59, hour 0-23, day-of-month 1-31, month 1-12, and day-of-week 0-7 where both `0` and `7` mean Sunday. Every field accepts `*`, a single value, an `a-b` range, a `*/n` or `a-b/n` step with `n >= 1`, and a comma-separated list of those forms. `L`, `W`, `#`, `JAN`/`MON` names, `@daily`-style macros, six-field expressions, out-of-range values, inverted ranges, zero steps, and empty fields are rejected with `invalid_rule` and a message naming the offending field. Because the dialect has five fields, the smallest interval is one minute. Creation stores a canonical expression: repeated values collapse, adjacent values and ranges merge, a uniform step is written as `a-b/n` or, for a field that started with `*`, as a star-step (`*` for every value, otherwise the widest star-step walk plus any remaining values), a step of `1` is dropped, and Sunday is written as `0`. A field that did not start with `*` never becomes a star-step, so the day rule below survives storage. The durable decoder rejects a non-canonical stored expression, so the record always holds the canonical text. When either day-of-month or day-of-week is a star, a local date matches only when both fields match; when neither is a star, either field matching is enough. A field is a star when its text starts with `*`, independently of the values it matches, so a stepped star constrains alongside the other field instead of substituting for it. The first target is the first strictly future instant whose local date and time in that zone match, using the same gap skip and earlier-overlap rules as Daily. See [cron timing](../../../docs/subsystems/schedule.md#cron-wall-clock-input) for the full dialect and canonicalization rules. -A reminder is bound to the calling Agent's Session. The shared `schedule` Remote namespace exposes `catalog` for active and inactive Host tasks with their original Session ids, and `list`, `history`, `update`, and `delete` with an explicit Session id. Remote `list` and model `schedule_list` return only active tasks. Catalog entries retain only the latest receipt in `lastDelivery`; catalog and list responses omit saved delivery history. None of these operations activates an Agent or reads Session logs. Explicit deletion stops future delivery, leaves the original Session and already queued messages intact, and removes the stored task row together with its saved delivery records: the task leaves `list` and `catalog`, never schedules again, and `history` answers `schedule_not_found` for the same `(sessionId, id)`. +A reminder is bound to the calling Agent's Session. The shared `schedule` Remote namespace exposes `catalog` for active and inactive Host tasks with their original Session ids, and `list`, `history`, `update`, and `delete` with an explicit Session id. It also exposes `deleteInactive`, which removes every ended task across Sessions and answers how many rows it removed; an ended task never arms a timer, so that removal recomputes no target, and because rows are removed one at a time a failure partway through leaves the earlier removals committed and rejects instead of reporting a count. Remote `list` and model `schedule_list` return only active tasks. Catalog entries retain only the latest receipt in `lastDelivery`; catalog and list responses omit saved delivery history. None of these operations activates an Agent or reads Session logs. Explicit deletion stops future delivery, leaves the original Session and already queued messages intact, and removes the stored task row together with its saved delivery records: the task leaves `list` and `catalog`, never schedules again, and `history` answers `schedule_not_found` for the same `(sessionId, id)`. Archiving a Session with active reminders is refused until they stop, and choosing to stop them deletes every active reminder; unarchiving does not bring them back. diff --git a/packages/schedule/schedule/README.zh.md b/packages/schedule/schedule/README.zh.md index 73e744d038..5f938af66d 100644 --- a/packages/schedule/schedule/README.zh.md +++ b/packages/schedule/schedule/README.zh.md @@ -44,7 +44,7 @@ Schedule 将一次性、固定周期、按每日、按每周以及 cron 本地 cron 输入携带 `expression` 和 `time_zone`。表达式是标准五字段 Vixie 形式 `minute hour day-of-month month day-of-week`:minute 0-59、hour 0-23、day-of-month 1-31、month 1-12、day-of-week 0-7,其中 `0` 和 `7` 都表示周日。每个字段接受 `*`、单个值、`a-b` 范围、`n >= 1` 的 `*/n` 与 `a-b/n` 步长,以及由这些形式组成的逗号分隔列表。`L`、`W`、`#`、`JAN`/`MON` 名称、`@daily` 风格宏、六字段表达式、超出范围的值、反向范围、零步长和空字段都会以 `invalid_rule` 拒绝,且错误信息会指出违规字段。由于该方言只有五个字段,最小间隔为一分钟。创建时存储规范化表达式:重复值合并,相邻的值与范围合并,均匀步长写为 `a-b/n`,而以 `*` 开头的字段写为星号步长(匹配全部取值时写 `*`,否则写最宽的星号步长并追加其余取值),步长 `1` 被移除,周日统一写为 `0`。不以 `*` 开头的字段绝不会变成星号步长,因此下文的日规则在存储后保持不变。持久化解码器拒绝非规范化的已存储表达式,因此记录始终保存规范化文本。day-of-month 或 day-of-week 中任一为星号字段时,本地日期需两个字段都匹配;两者都不是星号字段时,匹配任一字段即可。字段文本以 `*` 开头即为星号字段,与它匹配的取值集合无关,因此步长星号字段与其他字段同时约束,而不是替代它。首个目标是该时区内本地日期与时间匹配的第一个严格未来时点,使用与 Daily 相同的缺口跳过与重叠取较早时点规则。完整方言与规范化规则见 [cron 时间语义](../../../docs/subsystems/schedule.zh.md#cron-wall-clock-input)。 -提醒绑定到调用 Agent 的会话。共享的 `schedule` Remote namespace 提供 `catalog`,返回活动和未运行的宿主任务及其原始会话 id,并提供带明确会话 id 的 `list`、`history`、`update` 和 `delete`。Remote `list` 和模型 `schedule_list` 仅返回活动任务。目录条目的 `lastDelivery` 仅保留最近一次回执;目录和列表响应均不包含已保存的发送历史。这些操作均不激活 Agent 或读取会话日志。显式删除会阻止后续投递,保留原会话和已经入队的消息,并移除已存储的任务行及其已保存的发送记录:任务从 `list` 和 `catalog` 中消失、不再调度,`history` 对相同的 `(sessionId, id)` 返回 `schedule_not_found`。 +提醒绑定到调用 Agent 的会话。共享的 `schedule` Remote namespace 提供 `catalog`,返回活动和未运行的宿主任务及其原始会话 id,并提供带明确会话 id 的 `list`、`history`、`update` 和 `delete`。它还提供 `deleteInactive`,删除所有会话中全部未运行任务并返回移除的行数;未运行任务从不会安排定时器,因此该删除不重算任何目标,且由于逐行移除,中途失败时先前已移除的行保持已移除,调用以拒绝结束而不返回计数。Remote `list` 和模型 `schedule_list` 仅返回活动任务。目录条目的 `lastDelivery` 仅保留最近一次回执;目录和列表响应均不包含已保存的发送历史。这些操作均不激活 Agent 或读取会话日志。显式删除会阻止后续投递,保留原会话和已经入队的消息,并移除已存储的任务行及其已保存的发送记录:任务从 `list` 和 `catalog` 中消失、不再调度,`history` 对相同的 `(sessionId, id)` 返回 `schedule_not_found`。 归档仍有活动提醒的会话会被拒绝,直到这些提醒停止;选择停止它们会删除全部活动提醒,取消归档不会把它们带回来。 diff --git a/packages/schedule/schedule/src/index.ts b/packages/schedule/schedule/src/index.ts index b9144c5347..844af460f3 100644 --- a/packages/schedule/schedule/src/index.ts +++ b/packages/schedule/schedule/src/index.ts @@ -19,9 +19,9 @@ import { scheduleTitle, } from './domain.ts' import type { - DeliveryRetentionBounds, ScheduleCatalogEntry, ScheduleCreateRequest, ScheduleDeleteRequest, ScheduleDeleteResult, - ScheduleDeliveryHistoryRequest, ScheduleDeliveryHistoryResult, ScheduleListRequest, ScheduleRecord, - ScheduleUpdateRequest, ScheduleUpdateResult, SubagentSessionError, + DeliveryRetentionBounds, ScheduleCatalogEntry, ScheduleCreateRequest, ScheduleDeleteInactiveResult, + ScheduleDeleteRequest, ScheduleDeleteResult, ScheduleDeliveryHistoryRequest, ScheduleDeliveryHistoryResult, + ScheduleListRequest, ScheduleRecord, ScheduleUpdateRequest, ScheduleUpdateResult, SubagentSessionError, } from './types.ts' export type * from './types.ts' @@ -366,6 +366,43 @@ export class ScheduleService extends TypertRemoteService { }) } + /** + * Delete every ended Host task, leaving active tasks armed. + * + * Ended tasks never schedule another delivery, so the removal changes no pending + * target and no timer is recomputed. Rows are enumerated and removed in one FIFO + * slot, so a create ordered behind this call is not removed by it. Each removed row + * is durable on its own: a failure partway through leaves the earlier removals + * committed and announced, then rejects rather than reporting a partial count as + * the whole. + * @returns Count of removed rows; zero when the Host held no ended task. + */ + @Remote('deleteInactive') + async deleteInactive(): Promise { + return this.serialize(async () => { + const tasks = (await this.getDomain()).table('tasks') + const ended = [...tasks.entries()] + .filter(([, task]) => task.status === 'inactive') + .map(([, task]) => task.record.id) + let deleted = 0 + let failure: Error | undefined + for (const id of ended) { + try { + // A row another queued write already removed is not this call's removal. + if (await tasks.delete(id)) deleted += 1 + } catch (error: unknown) { + // Every row is attempted, so one failed delete does not leave the rest + // of the ended rows behind. A rejection reason that carries no Error is + // wrapped before the first one is kept. + failure ??= error instanceof Error ? error : new Error(`schedule ended-task removal failed: ${String(error)}`) + } + } + if (deleted > 0) this.emitChanged() + if (failure !== undefined) throw failure + return { deleted } + }) + } + /** * Update the name, instruction, and timing of an active task within the original Session * binding without activating the Session or changing saved deliveries. diff --git a/packages/schedule/schedule/src/types.ts b/packages/schedule/schedule/src/types.ts index d072de64b8..27ebe667a2 100644 --- a/packages/schedule/schedule/src/types.ts +++ b/packages/schedule/schedule/src/types.ts @@ -349,6 +349,12 @@ export type ScheduleDeleteResult = /** Canonical `schedule_delete` value. */ export type ScheduleDeleteValue = ScheduleDeleteResult | ScheduleToolError +/** Successful Host-wide removal of every ended task, whether or not any remained. */ +export interface ScheduleDeleteInactiveResult { + /** Ended task rows this call removed; zero when the Host held none. */ + readonly deleted: number +} + declare module '@deepseek-ai/dsh-workspace/types' { interface SessionActivityKindMap { /** A scheduled follow-up for this session is still active. */ diff --git a/packages/schedule/schedule/tests/delete-inactive.spec.ts b/packages/schedule/schedule/tests/delete-inactive.spec.ts new file mode 100644 index 0000000000..6080e57d77 --- /dev/null +++ b/packages/schedule/schedule/tests/delete-inactive.spec.ts @@ -0,0 +1,170 @@ +/** Host-wide removal of every ended task shares per-row deletion's durability rules. */ +import { SessionId } from '@deepseek-ai/dsh-session' +import { MessageId } from '@deepseek-ai/dsh-llm/brand' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { createAfterScheduleRecord, ScheduleId } from '../src/domain.ts' +import { scheduleDomain, type ScheduleTask } from '../src/storage.ts' +import { MemoryMediaPool } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' +import { gatedScheduleBackend, harness } from './harness.ts' + +const tests: Awaited>[] = [] +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-09-16T00:00:00Z')) +}) +afterEach(async () => { + await Promise.all(tests.splice(0).map(test => test.ctx.fiber.dispose())) + vi.useRealTimers() +}) + +/** + * Build one ended task carrying one saved delivery. + * @param sessionId - Session the task was created in. + * @param id - stored task identity. + * @param title - stored task name. + * @returns the row a delivered one-shot leaves behind. + */ +function endedRow(sessionId: SessionId, id: string, title: string): ScheduleTask { + const receipt = { + scheduledAt: '2026-09-15T23:59:00.000Z', + deliveredAt: '2026-09-15T23:59:01.000Z', + messageId: MessageId(`${id}-delivery`), + } + return { + sessionId, + record: createAfterScheduleRecord(ScheduleId(id), title, 60, Date.now(), title), + status: 'inactive', + lastDelivery: receipt, + deliveryHistory: { records: [receipt], earlierRecordsUnavailable: false }, + } +} + +/** + * Boot a service whose store already holds the given rows. + * @param rows - task rows present before activation. + * @returns the started harness with its storage pool. + */ +async function seeded(rows: readonly ScheduleTask[]) { + const test = await harness({ beforeService: async (_ctx, facility) => { + const domain = await facility.open(scheduleDomain) + for (const row of rows) await domain.table('tasks').put(row.record.id, row) + await domain.close() + } }) + tests.push(test) + return test +} + +it('removes ended tasks across Sessions with their saved deliveries and keeps active ones', async () => { + const firstSession = SessionId('ended-first') + const secondSession = SessionId('ended-second') + const armedSession = SessionId('still-armed') + const endedFirst = endedRow(firstSession, 'ended-a', 'Ended first') + const endedSecond = endedRow(secondSession, 'ended-b', 'Ended second') + const armed = createAfterScheduleRecord(ScheduleId('armed'), 'Still armed', 3_600, Date.now(), 'Still armed') + const test = await seeded([endedFirst, endedSecond, { + sessionId: armedSession, record: armed, status: 'active', + }]) + const { ctx, service, resolve } = test + const changed = vi.fn() + ctx.on('schedule/changed', changed) + + expect(await service.deleteInactive()).toEqual({ deleted: 2 }) + + expect((await service.catalog()).map(entry => entry.title)).toEqual(['Still armed']) + expect(await service.list({ sessionId: firstSession })).toEqual([]) + // The saved delivery records went with their rows, so history no longer resolves them. + expect(await service.history({ sessionId: firstSession, id: endedFirst.record.id, limit: 20 })) + .toEqual({ id: endedFirst.record.id, code: 'schedule_not_found' }) + expect(await service.history({ sessionId: secondSession, id: endedSecond.record.id, limit: 20 })) + .toEqual({ id: endedSecond.record.id, code: 'schedule_not_found' }) + expect(changed).toHaveBeenCalledTimes(1) + expect(resolve).not.toHaveBeenCalled() + expect(ctx.agents.roots()).toEqual([]) +}) + +it('reports zero and announces nothing when the Host holds no ended task', async () => { + const sessionId = SessionId('only-active') + const test = await seeded([{ + sessionId, + record: createAfterScheduleRecord(ScheduleId('armed'), 'Still armed', 3_600, Date.now(), 'Still armed'), + status: 'active', + }]) + const { ctx, service } = test + const changed = vi.fn() + ctx.on('schedule/changed', changed) + + expect(await service.deleteInactive()).toEqual({ deleted: 0 }) + expect(changed).not.toHaveBeenCalled() + expect(await service.catalog()).toHaveLength(1) +}) + +it('keeps the removals that landed, announces them, and reports the failure', async () => { + const pool = new MemoryMediaPool() + let deletes = 0 + // The first ended row lands durably; the second row's physical delete rejects. + const backend = gatedScheduleBackend(pool, { + deleteRecord: async (table) => { + if (table !== 'tasks') return + deletes += 1 + if (deletes === 2) throw new Error('injected delete failure') + }, + }) + const test = await harness({ + pool, + backend, + beforeService: async (_ctx, facility) => { + const domain = await facility.open(scheduleDomain) + await domain.table('tasks').put(ScheduleId('ended-a'), endedRow(SessionId('partial-a'), 'ended-a', 'Partial first')) + await domain.table('tasks').put(ScheduleId('ended-b'), endedRow(SessionId('partial-b'), 'ended-b', 'Partial second')) + await domain.close() + }, + }) + tests.push(test) + const { ctx, service } = test + const changed = vi.fn() + ctx.on('schedule/changed', changed) + + const failure = await service.deleteInactive().then(() => undefined, (error: unknown) => error) + + expect(failure).toBeInstanceOf(Error) + expect(String(failure)).toContain('injected delete failure') + // The row removed before the failure is a durable change observers must hear. + expect(changed).toHaveBeenCalledTimes(1) + expect((await service.catalog()).map(entry => entry.title)).toEqual(['Partial second']) +}) + +it('attempts every row and reports a rejection reason that carries no Error', async () => { + const pool = new MemoryMediaPool() + let deletes = 0 + const backend = gatedScheduleBackend(pool, { + deleteRecord: async (table) => { + if (table !== 'tasks') return + deletes += 1 + throw undefined + }, + }) + const test = await harness({ + pool, + backend, + beforeService: async (_ctx, facility) => { + const domain = await facility.open(scheduleDomain) + await domain.table('tasks').put(ScheduleId('ended-a'), endedRow(SessionId('unreportable-a'), 'ended-a', 'Unreportable first')) + await domain.table('tasks').put(ScheduleId('ended-b'), endedRow(SessionId('unreportable-b'), 'ended-b', 'Unreportable second')) + await domain.close() + }, + }) + tests.push(test) + const { ctx, service } = test + const changed = vi.fn() + ctx.on('schedule/changed', changed) + + const failure = await service.deleteInactive().then(() => undefined, (error: unknown) => error) + + // A reason that carries no Error must not read as a successful removal. + expect(failure).toBeInstanceOf(Error) + expect(String(failure)).toContain('schedule ended-task removal failed') + expect(deletes).toBe(2) + expect(changed).not.toHaveBeenCalled() + expect((await service.catalog()).map(entry => entry.title).sort()) + .toEqual(['Unreportable first', 'Unreportable second']) +}) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index f5791d537d..f308acbfe0 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -545,6 +545,7 @@ export const LINK_MAP: Readonly> = { ScheduleListRequest: 'schedule.md', ScheduleDeleteRequest: 'schedule.md', ScheduleDeleteResult: 'schedule.md', + ScheduleDeleteInactiveResult: 'schedule.md', ScheduleTimingChange: 'schedule.md', ScheduleUpdateRequest: 'schedule.md', ScheduleUpdateResult: 'schedule.md', From 422aa724e32c2e91951d4e25c5a7f444270267f8 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 20 Oct 2026 18:41:33 +0800 Subject: [PATCH 02/85] fix(schedule): qualify the clear-ended copy and pin its tests --- apps/web/tests/schedule-after.e2e.ts | 85 +++++++++++-------- docs/subsystems/schedule.i18n.yaml | 4 +- docs/subsystems/schedule.md | 18 ++-- docs/subsystems/schedule.zh.md | 18 ++-- packages/client/ui-schedule/README.i18n.yaml | 4 +- packages/client/ui-schedule/README.md | 4 +- packages/client/ui-schedule/README.zh.md | 4 +- .../ui-schedule/src/client/DeleteToast.tsx | 4 +- .../src/client/TaskManagerPage.module.css | 5 +- .../src/client/TaskManagerPage.tsx | 2 +- .../src/client/task-manager-locales.ts | 16 ++-- .../tests/clear-ended.client.spec.ts | 23 +++-- .../tests/delete-toast.client.spec.tsx | 11 ++- .../tests/task-manager-page.client.spec.tsx | 4 +- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/schedule/schedule/src/index.ts | 8 +- .../schedule/tests/delete-inactive.spec.ts | 14 ++- scripts/type-equiv.manifest.json | 5 ++ 18 files changed, 146 insertions(+), 85 deletions(-) diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index d67aa3ab41..6b4bb9aa9a 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -1267,9 +1267,12 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { if (domain === undefined) throw new Error('Schedule domain was not opened') const endedIds = [ScheduleId('schedule-ended-first'), ScheduleId('schedule-ended-second')] const endedTitles = ['Ended release check', 'Ended metrics check'] as const + // An earlier scenario may leave an ended row behind; the confirmation states + // the whole ended set, so the lane starts from an empty one. + await scaffold.ctx.schedule.deleteInactive() for (const [index, id] of endedIds.entries()) { const title = endedTitles[index]! - const record = createAfterScheduleRecord(ScheduleId(id), title, 60, Date.now(), title) + const record = createAfterScheduleRecord(id, title, 60, Date.now(), title) const receipt = { scheduledAt: record.scheduledAt, deliveredAt: new Date(Date.parse(record.scheduledAt) + 1_000).toISOString(), @@ -1280,45 +1283,55 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { deliveryHistory: { records: [receipt], earlierRecordsUnavailable: false }, }) } - // A create is the Host's own committed change, so its notification is what - // makes the open page read the rows seeded above. + // The running task is what the cleanup must leave alone. Creating it also + // announces a committed Host change, so the open page reads the rows seeded + // above without waiting for its own first catalog read. const kept = await scaffold.ctx.schedule.create(endedSession, { prompt: 'Keep this running task', title: 'Keep this running task', at: '2099-08-25T12:08:00.000Z', }) - await page.getByRole('button', { name: 'Automation tasks', exact: true }).click() - const manager = page.getByTestId('task-manager-page') - // Earlier scenarios leave their own search text and status filter on this - // shared page; this scenario asserts the complete list. - await manager.getByRole('searchbox', { name: 'Search tasks' }).fill('') - await manager.getByRole('button', { name: 'All', exact: true }).click() - const clear = manager.getByRole('button', { name: 'Clear ended', exact: true }) - await clear.waitFor({ timeout: 15_000 }) + try { + await page.getByRole('button', { name: 'Automation tasks', exact: true }).click() + const manager = page.getByTestId('task-manager-page') + // Earlier scenarios leave their own search text and status filter on this + // shared page; this scenario asserts the complete list. + await manager.getByRole('searchbox', { name: 'Search tasks' }).fill('') + await manager.getByRole('button', { name: 'All', exact: true }).click() + const clear = manager.getByRole('button', { name: 'Clear ended', exact: true }) + await clear.waitFor({ timeout: 15_000 }) - // The confirmation states the Host-side target and removes nothing until it - // is confirmed. - await clear.click() - const confirmation = page.getByRole('dialog', { name: 'Clear ended automation tasks?' }) - await confirmation.getByText('2 ended tasks', { exact: true }).waitFor() - await confirmation.getByRole('button', { name: 'Cancel', exact: true }).click() - expect((await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive')).toHaveLength(2) + // The confirmation states the Host-side target and removes nothing until it + // is confirmed. + await clear.click() + const confirmation = page.getByRole('dialog', { name: 'Clear ended automation tasks?' }) + await confirmation.getByText('2 ended tasks', { exact: true }).waitFor() + await confirmation.getByRole('button', { name: 'Cancel', exact: true }).click() + expect((await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive')).toHaveLength(2) - await clear.click() - await confirmation.getByRole('button', { name: 'Clear tasks', exact: true }).click() - await expect.poll(async () => (await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive')) - .toEqual([]) - const remaining = await scaffold.ctx.schedule.catalog() - expect(remaining.some(task => endedIds.includes(task.id))).toBe(false) - expect(remaining.map(task => task.title)).toContain('Keep this running task') - await page.getByText('Cleared 2 ended tasks.', { exact: true }).waitFor({ timeout: 15_000 }) - // The page follows the acknowledged removal: no ended row or cleanup action - // remains, and the running task is still listed. - await expect.poll(() => manager.getByRole('button', { name: 'Clear ended', exact: true }).count(), { timeout: 15_000 }) - .toBe(0) - await manager.getByRole('button', { name: 'All', exact: true }).click() - await expect.poll(() => manager.getByRole('list', { name: 'Task catalog' }) - .getByRole('button', { name: kept.title, exact: true }).count(), { timeout: 15_000 }).toBe(1) - expect((await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive')).toEqual([]) - expect(tripwire.pageErrors).toEqual([]) - await scaffold.ctx.schedule.delete({ sessionId: endedSession, id: kept.id }) + await clear.click() + await confirmation.getByRole('button', { name: 'Clear tasks', exact: true }).click() + await expect.poll( + async () => (await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive'), + { timeout: 15_000 }, + ).toEqual([]) + const remaining = await scaffold.ctx.schedule.catalog() + expect(remaining.some(task => endedIds.includes(task.id))).toBe(false) + expect(remaining.map(task => task.title)).toContain('Keep this running task') + await page.getByText('Cleared 2 ended tasks.', { exact: true }).waitFor({ timeout: 15_000 }) + // The page follows the acknowledged removal: no ended row or cleanup action + // remains, and the running task is still listed. + await expect.poll(() => manager.getByRole('button', { name: 'Clear ended', exact: true }).count(), { timeout: 15_000 }) + .toBe(0) + await manager.getByRole('button', { name: 'All', exact: true }).click() + await expect.poll(() => manager.getByRole('list', { name: 'Task catalog' }) + .getByRole('button', { name: kept.title, exact: true }).count(), { timeout: 15_000 }).toBe(1) + expect((await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive')).toEqual([]) + 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 }) + } + } }, 60_000) }) diff --git a/docs/subsystems/schedule.i18n.yaml b/docs/subsystems/schedule.i18n.yaml index 79a3fb77bc..fdd931c7f2 100644 --- a/docs/subsystems/schedule.i18n.yaml +++ b/docs/subsystems/schedule.i18n.yaml @@ -27,8 +27,8 @@ en: 93798ad69f74ddd5 zh: 634126ad8accc356 /host-wide-schedule/web-catalog: - en: 630b9879dddd5972 - zh: bfaddb3674ba5c98 + en: 30464765cbe9f81a + zh: eecf1ad0888b182f /host-wide-schedule/timing-edits: en: ce5d2095a3ad2e82 zh: 167b3715f8eaffc1 diff --git a/docs/subsystems/schedule.md b/docs/subsystems/schedule.md index b3f7fa31d2..8a794f8da9 100644 --- a/docs/subsystems/schedule.md +++ b/docs/subsystems/schedule.md @@ -309,6 +309,14 @@ type ScheduleDeleteResult = | { readonly id: ScheduleId; readonly deleted: false; readonly code: 'schedule_not_found' } ``` +```ts type-equiv +/** Successful Host-wide removal of every ended task, whether or not any remained. */ +interface ScheduleDeleteInactiveResult { + /** Ended task rows this call removed; zero when the Host held none. */ + readonly deleted: number +} +``` + ## Web catalog The Remote `schedule.catalog()` method returns active and inactive Host tasks with their original Session bindings, ordered by `scheduledAt` ascending and then lexicographically by `id`. It reads only the Schedule domain, without activating Sessions or reading their history. Browser consumers receive this browser-safe type: @@ -337,7 +345,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. `schedule.deleteInactive()` removes every ended task across Sessions, with the saved delivery records each row carries, and answers `{ deleted }` with the count it removed; the management page's bulk cleanup uses it. An ended task never arms a timer, so the removal recomputes no target. The payload-free `schedule/changed` event invalidates client lists; reconnecting clients fetch current state again. +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. `schedule.deleteInactive()` removes every ended (inactive) task across Sessions, with the saved delivery records each row carries; on success it answers `{ deleted }` with the count it removed, and a failure partway through rejects after the removals already committed and announced. The Automation tasks page's bulk cleanup uses it. An ended task never arms a timer, so the removal recomputes no target. The payload-free `schedule/changed` event invalidates client lists; reconnecting clients fetch current state again. The shipped Web composition 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. @@ -513,10 +521,10 @@ async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: Abor * * Ended tasks never schedule another delivery, so the removal changes no pending * target and no timer is recomputed. Rows are enumerated and removed in one FIFO - * slot, so a create ordered behind this call is not removed by it. Each removed row - * is durable on its own: a failure partway through leaves the earlier removals - * committed and announced, then rejects rather than reporting a partial count as - * the whole. + * slot, so a delivery ordered behind this call ends its task after the + * enumeration rather than during it. Each removed row is durable on its own: a + * failure partway through leaves the earlier removals committed and announced, + * then rejects rather than reporting a partial count as the whole. * @returns Count of removed rows; zero when the Host held no ended task. */ @Remote('deleteInactive') async deleteInactive(): Promise diff --git a/docs/subsystems/schedule.zh.md b/docs/subsystems/schedule.zh.md index 91e470890a..10356c08d2 100644 --- a/docs/subsystems/schedule.zh.md +++ b/docs/subsystems/schedule.zh.md @@ -309,6 +309,14 @@ type ScheduleDeleteResult = | { readonly id: ScheduleId; readonly deleted: false; readonly code: 'schedule_not_found' } ``` +```ts type-equiv +/** Successful Host-wide removal of every ended task, whether or not any remained. */ +interface ScheduleDeleteInactiveResult { + /** Ended task rows this call removed; zero when the Host held none. */ + readonly deleted: number +} +``` + ## Web 目录 Remote 方法 `schedule.catalog()` 返回活动和未运行的宿主任务及其原始 Session 绑定,先按 `scheduledAt` 升序排列,再按 `id` 字典序排列。它只读取 Schedule domain,不激活 Session 或读取其历史。浏览器消费方接收以下可安全用于浏览器的类型: @@ -337,7 +345,7 @@ type ScheduleCatalogEntry = ScheduleRecord & { } ``` -Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。`schedule.deleteInactive()` 删除跨会话的全部未运行任务,连同每行携带的已保存投递记录,并以它移除的计数答复 `{ deleted }`;自动化任务页面的批量清理使用该方法。未运行任务从不会安排定时器,因此该删除不重算任何目标。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。 +Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。`schedule.deleteInactive()` 删除跨会话的全部未运行(已结束)任务,连同每行携带的已保存的发送记录;成功时以它移除的计数答复 `{ deleted }`,中途失败时先提交并通知已移除的行,再以拒绝结束。自动化任务页面的批量清理使用该方法。未运行任务从不会安排定时器,因此该删除不重算任何目标。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。 随发行版交付的 Web 组合把 `ui-schedule` 与宿主能力一起挂载。[客户端包](../../packages/client/ui-schedule/README.zh.md) 负责目录、空状态与删除控件。页面单独筛选全部、活动和未运行任务,保留未运行任务详情及详情页签条内的原 Session 入口,并要求显式确认删除。“规则”和“任务运行记录”页签将任务设置与按需分页加载的已保存回执分开。回执与未运行状态均不确认模型执行。 @@ -513,10 +521,10 @@ async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: Abor * * Ended tasks never schedule another delivery, so the removal changes no pending * target and no timer is recomputed. Rows are enumerated and removed in one FIFO - * slot, so a create ordered behind this call is not removed by it. Each removed row - * is durable on its own: a failure partway through leaves the earlier removals - * committed and announced, then rejects rather than reporting a partial count as - * the whole. + * slot, so a delivery ordered behind this call ends its task after the + * enumeration rather than during it. Each removed row is durable on its own: a + * failure partway through leaves the earlier removals committed and announced, + * then rejects rather than reporting a partial count as the whole. * @returns Count of removed rows; zero when the Host held no ended task. */ @Remote('deleteInactive') async deleteInactive(): Promise diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index cfbb03bf72..bb1cd53097 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: 9f166d6ea79e3456 - zh: 02e18559999fc1e5 + en: 954dc495cfcce34d + zh: 9d861537037ff82c /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 67effaa929..1c6770f9e1 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 mounts `ui-schedule` with the Host Schedule service. The clock context that supplies the model's current time is preset-owned: `standard`, `cordis`, and `ptc` declare it, and `minimal` declares neither it nor the reminder tools. 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. Once the catalog holds an ended task, a bulk cleanup action sits left of New and removes every ended task after a confirmation that states how many the Host will remove. 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 shows a decorative document-and-clock illustration above its notice and New action, with colors that follow the light or dark theme. 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. +The shipped Web composition mounts `ui-schedule` with the Host Schedule service. The clock context that supplies the model's current time is preset-owned: `standard`, `cordis`, and `ptc` declare it, and `minimal` declares neither it nor the reminder tools. 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. Once the catalog holds an ended (inactive) task, a Clear ended action sits left of New and removes every ended task after a confirmation that states how many ended tasks the catalog currently holds. The page's 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 shows a decorative document-and-clock illustration above its notice and New action, with colors that follow the light or dark theme. 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. The page header keeps the title at its natural width and moves the actions to another line when the title and the actions no longer fit together. Each action label stays on one line. @@ -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, and the header popover — 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 ended rows left between the confirmation and the request, and `Could not clear the ended tasks.` after a failure that leaves the remaining ended rows in place to retry. Confirming a deletion closes the detail or the tab it was confirmed from, and 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 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. 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. diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index 7e5aa98da1..f8e1f1abf9 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` 与 Host Schedule 服务一起挂载。为模型提供当前时间的时钟上下文归 preset 所有:`standard`、`cordis` 与 `ptc` 声明它,`minimal` 既不声明它,也不声明提醒工具。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。目录中存在未运行任务后,“新建”左侧出现批量清理操作,经确认后移除全部未运行任务,确认提示说明 Host 将移除多少个。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示和“新建”操作上方显示装饰性的文档与时钟插画,配色跟随浅色或深色主题。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。页签条放不下页签列表和带文字的入口时,入口收为与“更多任务操作”同样式的图标按钮,使用会话对话图标,悬停提示“关联会话”,入口禁用时也会显示;切换形态时保留焦点;页签列表可滚动但不显示滚动条。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。 +随发行版交付的 Web 组合把 `ui-schedule` 与 Host Schedule 服务一起挂载。为模型提供当前时间的时钟上下文归 preset 所有:`standard`、`cordis` 与 `ptc` 声明它,`minimal` 既不声明它,也不声明提醒工具。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。目录中存在未运行任务后,“新建”左侧出现「清理已结束」操作,经确认后移除全部未运行任务,确认提示说明目录当前包含多少个未运行任务。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示和“新建”操作上方显示装饰性的文档与时钟插画,配色跟随浅色或深色主题。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。页签条放不下页签列表和带文字的入口时,入口收为与“更多任务操作”同样式的图标按钮,使用会话对话图标,悬停提示“关联会话”,入口禁用时也会显示;切换形态时保留焦点;页签列表可滚动但不显示滚动条。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。 页头按标题的自然宽度排布;标题和操作无法并排容纳时,将操作移到下一行。每个操作文案保持单行。 @@ -41,7 +41,7 @@ kind: "package-reference" 历史查询成功的空页与加载中、查询失败、任务不存在或游标无效相互区分。请求失败时保留此前加载的记录,并显示警告和“重试加载任务运行记录”;游标无效时提供“刷新任务运行记录”,重新加载最新一页。最新 `messageId` 变化时刷新最新一页,不切换页签。被刷新取代的响应,以及切换任务、离开任务运行记录或关闭详情后才收到的响应,不会替换当前视图。 -自动化任务页面要求确认后才能删除任务。删除是硬删除:Host 连同已保存的任务运行记录一起移除存储的任务行,该任务因此停止后续投递、离开 `list` 与 `catalog`,其投递历史不再可读。删除保留原会话和已入队消息;列表仅在权威查询确认移除之前保留该行。每条删除路径——自动化任务页的详情、会话的任务页签、以及页头弹层——都把最终结果写入同一个存储,由应用级 `shell.overlay` 提示渲染,因此提示的生命周期长于发起它的面板或页签:Host 连同已保存的运行记录移除该行后提示显示「任务已删除」,失败时显示「无法删除任务」,并保留规则以便重试。批量清理也由该提示报告:显示 Host 答复的行数「已清理 N 个已结束任务」;确认到请求之间未运行行已消失时显示「没有可清理的已结束任务」;失败时显示「无法清理已结束任务」,并保留尚未移除的未运行行以便重试。确认删除会关闭确认所在的面板或页签,每条提示按自身序号区分,后一条会重新开始横幅而不是并入前一条。查询失败保留最近已知记录,并显示独立的重试操作;不会呈现为查询成功的空结果。一次性任务在持久收件箱投递后仍以未运行状态保存。显式删除前,通过全部或未运行筛选仍可查看详情与原会话 id。最近一次投递显示其发生时间,不显示消息 id 和投递确认时间;未运行状态和该回执均不表示模型执行完成。 +自动化任务页面要求确认后才能删除任务。删除是硬删除:Host 连同已保存的任务运行记录一起移除存储的任务行,该任务因此停止后续投递、离开 `list` 与 `catalog`,其投递历史不再可读。删除保留原会话和已入队消息;列表仅在权威查询确认移除之前保留该行。每条删除路径——自动化任务页的详情、会话的任务页签、以及页头弹层——都把最终结果写入同一个存储,由应用级 `shell.overlay` 提示渲染,因此提示的生命周期长于发起它的面板或页签:Host 连同已保存的运行记录移除该行后提示显示「任务已删除」,失败时显示「无法删除任务」,并保留规则以便重试。批量清理也由该提示报告:显示 Host 答复的行数「已清理 N 个已结束任务」;Host 答复未移除任何行时显示「没有可清理的已结束任务」;失败时显示「无法清理已结束任务」,并保留尚未移除的未运行行以便重试。批量清理只关闭自己的确认对话框。确认删除会关闭确认所在的面板或页签,每条提示按自身序号区分,后一条会重新开始横幅而不是并入前一条。查询失败保留最近已知记录,并显示独立的重试操作;不会呈现为查询成功的空结果。一次性任务在持久收件箱投递后仍以未运行状态保存。显式删除前,通过全部或未运行筛选仍可查看详情与原会话 id。最近一次投递显示其发生时间,不显示消息 id 和投递确认时间;未运行状态和该回执均不表示模型执行完成。 已打开的会话只有在确实有可打开的内容时,才在页头右侧功能位中、紧邻更多操作菜单左侧渲染一个仅图标时钟按钮:首次读取前不渲染任何内容,读取结果为没有活动提醒时页头不显示该按钮,刷新期间保留上一次已知的提醒,读取失败则保留按钮及其重试。该规则避免时钟在首次结果前后出现又消失。其无障碍名称在读取就绪时给出提醒数量,否则显示通用提醒标签。当该会话只有一个活动提醒时,按钮直接在右侧栏打开该任务的详情。其余数量都会打开弹层:先列出逾期提醒,再按目标时间列出未来提醒,显示已存储的标题、频率、浏览器本地目标时间、相对时间及删除按钮;标题本身是一个按钮,点击后在右侧栏打开该任务的详情。任务页签在标签上显示当前任务已存储的标题,并提供与自动化任务页面相同的规则、任务运行记录、名称、指令和运行时间修改、确认删除和原会话入口;它不会激活会话。每日频率文本保留保存的本地时间和时区;每个下一目标时点都显示为设备时区的钟点和括号内的相对时长;规则自身的时区只显示在该频率文本中。Escape 关闭弹层并将焦点返回按钮;点击外部将其关闭。删除最后一条提醒会移除按钮;在此之前,已经打开的弹层保持可展开状态。 diff --git a/packages/client/ui-schedule/src/client/DeleteToast.tsx b/packages/client/ui-schedule/src/client/DeleteToast.tsx index 556f5a02bf..064809e524 100644 --- a/packages/client/ui-schedule/src/client/DeleteToast.tsx +++ b/packages/client/ui-schedule/src/client/DeleteToast.tsx @@ -102,7 +102,9 @@ export function ScheduleDeleteToast({ useToast, dismiss, t }: ScheduleDeleteToas // ended rows can leave between the confirmation and the request. return diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css index c76a57bc51..a36fc1ee32 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css @@ -87,9 +87,8 @@ white-space: nowrap; } -/* The Plugins toolbar's steps: 32px capsules with the 13px label. The New - action and the bulk cleanup share the geometry, so the pair reads as one - toolbar. */ +/* The Plugins toolbar's steps: New and the bulk cleanup share one 32px + capsule with a 13px label. */ .headerAction { height: 32px; font-size: 13px; diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx index 841b559cf8..afb83b2c18 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx @@ -261,7 +261,7 @@ export function TaskManagerPage(props: TaskManagerPageProps) { } > -

{t('clear.count', { count: endedCount })}

+

{t(endedCount === 1 ? 'clear.count.one' : 'clear.count.other', { count: endedCount })}

) 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 056f8a3cbb..cd17bb22a6 100644 --- a/packages/client/ui-schedule/src/client/task-manager-locales.ts +++ b/packages/client/ui-schedule/src/client/task-manager-locales.ts @@ -136,15 +136,17 @@ export const en = { 'delete.pending': 'Deleting…', 'clear.action': 'Clear ended', 'clear.title': 'Clear ended automation tasks?', - 'clear.description': 'These tasks stop triggering and are deleted together with their saved delivery records. ' + 'clear.description': 'Each task stops triggering and is deleted together with its saved delivery records. ' + 'The original sessions and their messages remain.', - 'clear.count': '{count} ended tasks', + 'clear.count.one': '{count} ended task', + 'clear.count.other': '{count} ended tasks', 'clear.confirm': 'Clear tasks', 'clear.pending': 'Clearing…', 'clear.close': 'Close clear confirmation', 'toast.deleted': 'Task deleted.', 'toast.deleteFailed': 'Could not delete the task.', - 'toast.cleared': 'Cleared {count} ended tasks.', + '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.', 'card.open': 'Open', @@ -289,14 +291,16 @@ export const zh: Record = { 'delete.pending': '正在删除…', 'clear.action': '清理已结束', 'clear.title': '清理已结束的自动化任务?', - 'clear.description': '这些任务将停止触发,并连同其保存的运行记录一并删除。原会话及其消息仍然保留', - 'clear.count': '共 {count} 个已结束任务', + 'clear.description': '每个任务将停止触发,并连同其保存的运行记录一并删除。原会话及其消息仍然保留', + 'clear.count.one': '共 {count} 个已结束任务', + 'clear.count.other': '共 {count} 个已结束任务', 'clear.confirm': '确认清理', 'clear.pending': '正在清理…', 'clear.close': '关闭清理确认', 'toast.deleted': '任务已删除', 'toast.deleteFailed': '无法删除任务', - 'toast.cleared': '已清理 {count} 个已结束任务', + 'toast.cleared.one': '已清理 {count} 个已结束任务', + 'toast.cleared.other': '已清理 {count} 个已结束任务', 'toast.clearedNone': '没有可清理的已结束任务', 'toast.clearFailed': '无法清理已结束任务', 'card.open': '打开', diff --git a/packages/client/ui-schedule/tests/clear-ended.client.spec.ts b/packages/client/ui-schedule/tests/clear-ended.client.spec.ts index d453deef50..ef943688b0 100644 --- a/packages/client/ui-schedule/tests/clear-ended.client.spec.ts +++ b/packages/client/ui-schedule/tests/clear-ended.client.spec.ts @@ -27,15 +27,26 @@ function setup( } describe('createClearEnded', () => { - it('reads the catalog back after an acknowledged removal and reports how many rows went', async () => { - const { clearEnded, removeInactive, refresh } = setup({ ok: true, value: { deleted: 3 } }) + it('reads the catalog back before the outcome settles and reports how many rows went', async () => { + const readback = Promise.withResolvers() + const { clearEnded, removeInactive, refresh } = setup( + { ok: true, value: { deleted: 3 } }, + vi.fn(() => readback.promise), + ) + let settled = false + const outcome = clearEnded().then((value) => { + settled = true + return value + }) - await expect(clearEnded()).resolves.toEqual({ cleared: true, count: 3 }) + await vi.waitFor(() => { expect(refresh).toHaveBeenCalledOnce() }) + // The list must agree with the count before the notice states it, so the + // outcome waits for the readback rather than publishing alongside it. + expect(settled).toBe(false) + readback.resolve(undefined) + await expect(outcome).resolves.toEqual({ cleared: true, count: 3 }) expect(removeInactive).toHaveBeenCalledOnce() - expect(refresh).toHaveBeenCalledOnce() - // The readback is what makes the list agree with the count, so it settles - // before the outcome is published. expect(removeInactive.mock.invocationCallOrder[0]) .toBeLessThan(refresh.mock.invocationCallOrder[0]!) }) diff --git a/packages/client/ui-schedule/tests/delete-toast.client.spec.tsx b/packages/client/ui-schedule/tests/delete-toast.client.spec.tsx index aadce87d0c..b7923087ad 100644 --- a/packages/client/ui-schedule/tests/delete-toast.client.spec.tsx +++ b/packages/client/ui-schedule/tests/delete-toast.client.spec.tsx @@ -80,12 +80,15 @@ describe('ScheduleDeleteToast', () => { expect(screen.getByRole('alert').textContent).toBe(zh['toast.deleteFailed']) }) - it('states how many ended tasks a bulk removal cleared', () => { + it('states how many ended tasks a bulk removal cleared, singular for one', () => { vi.useFakeTimers() - const { dismiss } = mount({ kind: 'cleared', count: 4, seq: 3 }) - expect(screen.getByRole('alert').textContent).toBe(en['toast.cleared'].replace('{count}', '4')) + const many = mount({ kind: 'cleared', count: 4, seq: 3 }) + expect(screen.getByRole('alert').textContent).toBe(en['toast.cleared.other'].replace('{count}', '4')) act(() => { vi.runAllTimers() }) - expect(dismiss).toHaveBeenCalledOnce() + expect(many.dismiss).toHaveBeenCalledOnce() + cleanup() + mount({ kind: 'cleared', count: 1, seq: 4 }) + expect(screen.getByRole('alert').textContent).toBe(en['toast.cleared.one'].replace('{count}', '1')) }) it('states that a bulk removal found nothing instead of a zero count', () => { 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 dad39c52ba..816e297421 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 @@ -979,7 +979,7 @@ describe('clearing ended tasks', () => { fireEvent.click(screen.getByRole('button', { name: en['clear.action'] })) const dialog = screen.getByRole('dialog', { name: en['clear.title'] }) expect(within(dialog).getByText(en['clear.description'])).toBeDefined() - expect(within(dialog).getByText(en['clear.count'].replace('{count}', '2'))).toBeDefined() + expect(within(dialog).getByText(en['clear.count.other'].replace('{count}', '2'))).toBeDefined() }) it('removes nothing when the confirmation is dismissed', () => { @@ -1030,7 +1030,7 @@ describe('clearing ended tasks', () => { mount({ records: [at, endedTask] }, zh) fireEvent.click(screen.getByRole('button', { name: zh['clear.action'] })) const dialog = screen.getByRole('dialog', { name: zh['clear.title'] }) - expect(within(dialog).getByText(zh['clear.count'].replace('{count}', '1'))).toBeDefined() + expect(within(dialog).getByText(zh['clear.count.one'].replace('{count}', '1'))).toBeDefined() expect(within(dialog).getByRole('button', { name: zh['clear.confirm'] })).toBeDefined() }) }) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index c14c966f8d..8235ff2645 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2202,7 +2202,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: '@Remote(\'deleteInactive\') async deleteInactive(): Promise', - description: 'Delete every ended Host task, leaving active tasks armed.\n\nEnded tasks never schedule another delivery, so the removal changes no pending target and no timer is recomputed. Rows are enumerated and removed in one FIFO slot, so a create ordered behind this call is not removed by it. Each removed row is durable on its own: a failure partway through leaves the earlier removals committed and announced, then rejects rather than reporting a partial count as the whole.', + description: 'Delete every ended Host task, leaving active tasks armed.\n\nEnded tasks never schedule another delivery, so the removal changes no pending target and no timer is recomputed. Rows are enumerated and removed in one FIFO slot, so a delivery ordered behind this call ends its task after the enumeration rather than during it. Each removed row is durable on its own: a failure partway through leaves the earlier removals committed and announced, then rejects rather than reporting a partial count as the whole.', parameters: [], returns: 'Count of removed rows; zero when the Host held no ended task.', }, diff --git a/packages/schedule/schedule/src/index.ts b/packages/schedule/schedule/src/index.ts index 844af460f3..1735f5eab2 100644 --- a/packages/schedule/schedule/src/index.ts +++ b/packages/schedule/schedule/src/index.ts @@ -371,10 +371,10 @@ export class ScheduleService extends TypertRemoteService { * * Ended tasks never schedule another delivery, so the removal changes no pending * target and no timer is recomputed. Rows are enumerated and removed in one FIFO - * slot, so a create ordered behind this call is not removed by it. Each removed row - * is durable on its own: a failure partway through leaves the earlier removals - * committed and announced, then rejects rather than reporting a partial count as - * the whole. + * slot, so a delivery ordered behind this call ends its task after the + * enumeration rather than during it. Each removed row is durable on its own: a + * failure partway through leaves the earlier removals committed and announced, + * then rejects rather than reporting a partial count as the whole. * @returns Count of removed rows; zero when the Host held no ended task. */ @Remote('deleteInactive') diff --git a/packages/schedule/schedule/tests/delete-inactive.spec.ts b/packages/schedule/schedule/tests/delete-inactive.spec.ts index 6080e57d77..a0913f4608 100644 --- a/packages/schedule/schedule/tests/delete-inactive.spec.ts +++ b/packages/schedule/schedule/tests/delete-inactive.spec.ts @@ -102,11 +102,17 @@ it('keeps the removals that landed, announces them, and reports the failure', as const pool = new MemoryMediaPool() let deletes = 0 // The first ended row lands durably; the second row's physical delete rejects. + // Iteration order is a snapshot detail, so the case records which row failed + // instead of assuming which of the two that is. + let failedId: string | undefined const backend = gatedScheduleBackend(pool, { - deleteRecord: async (table) => { + deleteRecord: async (table, key) => { if (table !== 'tasks') return deletes += 1 - if (deletes === 2) throw new Error('injected delete failure') + if (deletes === 2) { + failedId = key + throw new Error('injected delete failure') + } }, }) const test = await harness({ @@ -130,7 +136,9 @@ it('keeps the removals that landed, announces them, and reports the failure', as expect(String(failure)).toContain('injected delete failure') // The row removed before the failure is a durable change observers must hear. expect(changed).toHaveBeenCalledTimes(1) - expect((await service.catalog()).map(entry => entry.title)).toEqual(['Partial second']) + expect(failedId).toBeDefined() + // The survivor is the row whose delete failed; the other row stayed removed. + expect((await service.catalog()).map(entry => entry.id)).toEqual([failedId]) }) it('attempts every row and reports a rejection reason that carries no Error', async () => { diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index a7c0978c25..9a3b9d58a9 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -302,6 +302,11 @@ "symbol": "LegacyScheduleRecord", "source": "packages/schedule/schedule/src/types.ts" }, + { + "doc": "docs/subsystems/schedule.md", + "symbol": "ScheduleDeleteInactiveResult", + "source": "packages/schedule/schedule/src/types.ts" + }, { "doc": "docs/subsystems/schedule.md", "symbol": "LocalAtInput", From facfab5ce886a469ef1eddf5460026753573d61d Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 20 Oct 2026 18:48:46 +0800 Subject: [PATCH 03/85] fix(schedule): return focus when a cleanup removes its own trigger --- apps/web/tests/schedule-after.e2e.ts | 9 +++-- 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/DeleteToast.tsx | 21 ++++++----- .../src/client/TaskManagerPage.tsx | 28 ++++++++++++-- .../ui-schedule/src/client/clear-ended.ts | 4 +- .../src/client/task-manager-locales.ts | 4 +- .../tests/browser-plugin.client.spec.ts | 37 ++++++++++++++++++- .../tests/task-manager-page.client.spec.tsx | 31 ++++++++++++++++ packages/schedule/schedule/src/index.ts | 3 +- 11 files changed, 117 insertions(+), 28 deletions(-) diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 6b4bb9aa9a..4c4824a544 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -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) }) diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index bb1cd53097..543632f1be 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: 954dc495cfcce34d - zh: 9d861537037ff82c + en: c1556a0d3a5ea43b + zh: 3553ae81a0ef7410 /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 1c6770f9e1..4edebb05ef 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -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. diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index f8e1f1abf9..28e1dff63f 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -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 关闭弹层并将焦点返回按钮;点击外部将其关闭。删除最后一条提醒会移除按钮;在此之前,已经打开的弹层保持可展开状态。 diff --git a/packages/client/ui-schedule/src/client/DeleteToast.tsx b/packages/client/ui-schedule/src/client/DeleteToast.tsx index 064809e524..751b44c7a0 100644 --- a/packages/client/ui-schedule/src/client/DeleteToast.tsx +++ b/packages/client/ui-schedule/src/client/DeleteToast.tsx @@ -98,16 +98,17 @@ export function ScheduleDeleteToast({ useToast, dismiss, t }: ScheduleDeleteToas case 'deleteFailed': return } 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 + // 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 + ? + : case 'clearFailed': return } onDone={dismiss} /> /* v8 ignore next -- the store publishes only the union's members, so the default holds no reachable statement. */ diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx index afb83b2c18..893a2ecac1 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx @@ -66,6 +66,10 @@ export function TaskManagerPage(props: TaskManagerPageProps) { const { record: catalogRecord, task: selected, id: detailId, confirmId, setConfirmId, setTab } = detail const rowRef = useRef(null) const headingRef = useRef(null) + const clearTriggerRef = useRef(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) {

{t('title')}

{endedCount > 0 && }
diff --git a/packages/client/ui-schedule/src/client/clear-ended.ts b/packages/client/ui-schedule/src/client/clear-ended.ts index 99a42f7ad2..007d2b18d0 100644 --- a/packages/client/ui-schedule/src/client/clear-ended.ts +++ b/packages/client/ui-schedule/src/client/clear-ended.ts @@ -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. */ 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 cd17bb22a6..0ae01e8ea6 100644 --- a/packages/client/ui-schedule/src/client/task-manager-locales.ts +++ b/packages/client/ui-schedule/src/client/task-manager-locales.ts @@ -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 = { 'toast.cleared.one': '已清理 {count} 个已结束任务', 'toast.cleared.other': '已清理 {count} 个已结束任务', 'toast.clearedNone': '没有可清理的已结束任务', - 'toast.clearFailed': '无法清理已结束任务', + 'toast.clearFailed': '无法清理全部已结束任务', 'card.open': '打开', 'card.openLabel': '打开任务详情:{title}', 'card.deleted': '已删除', diff --git a/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts b/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts index 75b8f0c32d..6719db65a8 100644 --- a/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts @@ -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 + 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: () => () => {} }) 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 816e297421..a8dd653c9d 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 @@ -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() + 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' })) diff --git a/packages/schedule/schedule/src/index.ts b/packages/schedule/schedule/src/index.ts index 1735f5eab2..52cdcac68c 100644 --- a/packages/schedule/schedule/src/index.ts +++ b/packages/schedule/schedule/src/index.ts @@ -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 From 734812cdaddda2cb9b6ecc7eb3240b96eae2122c Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 20 Oct 2026 19:11:42 +0800 Subject: [PATCH 04/85] fix(schedule): align the clear copy with the inactive status and harden focus --- apps/web/tests/schedule-after.e2e.ts | 10 +-- packages/client/ui-schedule/README.i18n.yaml | 8 +- packages/client/ui-schedule/README.md | 4 +- packages/client/ui-schedule/README.zh.md | 14 ++-- .../src/client/TaskManagerPage.tsx | 20 +++-- .../src/client/task-manager-locales.ts | 20 ++--- .../tests/delete-toast.client.spec.tsx | 2 +- .../tests/task-manager-page.client.spec.tsx | 28 ++++++- packages/schedule/schedule/src/index.ts | 79 +++++++++---------- 9 files changed, 107 insertions(+), 78 deletions(-) diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 4c4824a544..8ac84949c9 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -1296,14 +1296,14 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { // shared page; this scenario asserts the complete list. await manager.getByRole('searchbox', { name: 'Search tasks' }).fill('') await manager.getByRole('button', { name: 'All', exact: true }).click() - const clear = manager.getByRole('button', { name: 'Clear ended', exact: true }) + const clear = manager.getByRole('button', { name: 'Clear inactive', exact: true }) await clear.waitFor({ timeout: 15_000 }) // The confirmation states the Host-side target and removes nothing until it // is confirmed. await clear.click() - const confirmation = page.getByRole('dialog', { name: 'Clear ended automation tasks?' }) - await confirmation.getByText('2 ended tasks', { exact: true }).waitFor() + const confirmation = page.getByRole('dialog', { name: 'Clear inactive automation tasks?' }) + await confirmation.getByText('2 inactive tasks', { exact: true }).waitFor() await confirmation.getByRole('button', { name: 'Cancel', exact: true }).click() expect((await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive')).toHaveLength(2) @@ -1316,10 +1316,10 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { const remaining = await scaffold.ctx.schedule.catalog() expect(remaining.some(task => endedIds.includes(task.id))).toBe(false) expect(remaining.map(task => task.title)).toContain('Keep this running task') - await page.getByText('Cleared 2 ended tasks.', { exact: true }).waitFor({ timeout: 15_000 }) + await page.getByText('Cleared 2 inactive tasks.', { exact: true }).waitFor({ timeout: 15_000 }) // The page follows the acknowledged removal: no ended row or cleanup action // remains, and the running task is still listed. - await expect.poll(() => manager.getByRole('button', { name: 'Clear ended', exact: true }).count(), { timeout: 15_000 }) + await expect.poll(() => manager.getByRole('button', { name: 'Clear inactive', exact: true }).count(), { timeout: 15_000 }) .toBe(0) await manager.getByRole('button', { name: 'All', exact: true }).click() await expect.poll(() => manager.getByRole('list', { name: 'Task catalog' }) diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index 543632f1be..ca6c1b029b 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/packages/client/ui-schedule/README.i18n.yaml @@ -10,19 +10,19 @@ zh: eb3e5d5a802da592 /deepseek-ai-dsh-client-ui-schedule/summary: en: 8dfc371c14bb4415 - zh: 9b3545c1a7468a7a + zh: 5e2fa588983524ed /deepseek-ai-dsh-client-ui-schedule/table-of-contents: en: d152484eb41ac6b4 zh: 405b30feaf48c087 /deepseek-ai-dsh-client-ui-schedule/use-this-package: - en: c1556a0d3a5ea43b - zh: 3553ae81a0ef7410 + en: 79522bdbf2a9aa32 + zh: 1a3f20fda3531151 /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: 959145f9a08c3913 - zh: caae642a82d2acd3 + zh: 7c0afb742790c474 /deepseek-ai-dsh-client-ui-schedule/further-exploration: en: 06e265120b05dbf5 zh: 08ab1ef979b8ea40 diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index 4edebb05ef..fc9242eeb5 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 mounts `ui-schedule` with the Host Schedule service. The clock context that supplies the model's current time is preset-owned: `standard`, `cordis`, and `ptc` declare it, and `minimal` declares neither it nor the reminder tools. 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. Once the catalog holds an ended (inactive) task, a Clear ended action sits left of New and removes every ended task after a confirmation that states how many ended tasks the catalog currently holds. The page's 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 shows a decorative document-and-clock illustration above its notice and New action, with colors that follow the light or dark theme. 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. +The shipped Web composition mounts `ui-schedule` with the Host Schedule service. The clock context that supplies the model's current time is preset-owned: `standard`, `cordis`, and `ptc` declare it, and `minimal` declares neither it nor the reminder tools. 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. Once the catalog holds an inactive task, a Clear inactive action sits left of New and removes every inactive task after a confirmation that states how many inactive tasks the catalog currently holds. The page's 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 shows a decorative document-and-clock illustration above its notice and New action, with colors that follow the light or dark theme. 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. The page header keeps the title at its natural width and moves the actions to another line when the title and the actions no longer fit together. Each action label stays on one line. @@ -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 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. +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 inactive tasks.` with the count the Host answered, `No inactive tasks to clear.` when the Host reports that it removed none, and `Could not clear every inactive task.` after a failure that leaves the remaining inactive 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. diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index 28e1dff63f..2358b75de4 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -在「自动化任务」页面和右侧栏的任务页签中管理各个会话的活动和未运行定时任务:搜索、筛选、修改名称、指令和运行时间、浏览任务运行记录、删除任务,或打开原会话。每个界面都用任务已存储的标题命名任务。已打开会话在存在活动提醒时,页头显示仅图标的提醒时钟,点击在右侧栏打开该任务详情;`schedule_create` 调用把创建的任务渲染为会话卡片;侧栏中带活动任务的未归档空闲会话行显示时钟标记,其悬停卡片列出这些任务。 +在「自动化任务」页面和右侧栏的任务页签中管理各个会话的活动和已结束定时任务:搜索、筛选、修改名称、指令和运行时间、浏览任务运行记录、删除任务,或打开原会话。每个界面都用任务已存储的标题命名任务。已打开会话在存在活动提醒时,页头显示仅图标的提醒时钟,点击在右侧栏打开该任务详情;`schedule_create` 调用把创建的任务渲染为会话卡片;侧栏中带活动任务的未归档空闲会话行显示时钟标记,其悬停卡片列出这些任务。 ## 目录 @@ -25,15 +25,15 @@ kind: "package-reference" ## 使用本包 -随发行版交付的 Web 组合把 `ui-schedule` 与 Host Schedule 服务一起挂载。为模型提供当前时间的时钟上下文归 preset 所有:`standard`、`cordis` 与 `ptc` 声明它,`minimal` 既不声明它,也不声明提醒工具。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。目录中存在未运行任务后,“新建”左侧出现「清理已结束」操作,经确认后移除全部未运行任务,确认提示说明目录当前包含多少个未运行任务。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示和“新建”操作上方显示装饰性的文档与时钟插画,配色跟随浅色或深色主题。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。页签条放不下页签列表和带文字的入口时,入口收为与“更多任务操作”同样式的图标按钮,使用会话对话图标,悬停提示“关联会话”,入口禁用时也会显示;切换形态时保留焦点;页签列表可滚动但不显示滚动条。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。 +随发行版交付的 Web 组合把 `ui-schedule` 与 Host Schedule 服务一起挂载。为模型提供当前时间的时钟上下文归 preset 所有:`standard`、`cordis` 与 `ptc` 声明它,`minimal` 既不声明它,也不声明提醒工具。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。目录中存在已结束任务后,“新建”左侧出现「清理已结束」操作,经确认后移除全部已结束任务,确认提示说明目录当前包含多少个已结束任务。列表支持文本搜索和一行“全部/已开启/已结束”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示和“新建”操作上方显示装饰性的文档与时钟插画,配色跟随浅色或深色主题。选择活动或已结束任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。页签条放不下页签列表和带文字的入口时,入口收为与“更多任务操作”同样式的图标按钮,使用会话对话图标,悬停提示“关联会话”,入口禁用时也会显示;切换形态时保留焦点;页签列表可滚动但不显示滚动条。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。 页头按标题的自然宽度排布;标题和操作无法并排容纳时,将操作移到下一行。每个操作文案保持单行。 -从列表选择任务时默认打开“规则”;刷新同一任务会保留当前页签。活动任务的“规则”修改名称、指令和运行时间;这些修改保留在本地草稿中直到保存,未运行任务保持只读。不带修饰键的左右方向键循环切换页签,Home/End 选择第一个/最后一个页签,键盘焦点随选择移动。页签不会拦截这些键与 Alt、Ctrl、Meta 或 Shift 的组合。共用的会话入口位于详情页签条内,在“规则”页签可见;确认删除操作在两种页签下都可用。 +从列表选择任务时默认打开“规则”;刷新同一任务会保留当前页签。活动任务的“规则”修改名称、指令和运行时间;这些修改保留在本地草稿中直到保存,已结束任务保持只读。不带修饰键的左右方向键循环切换页签,Home/End 选择第一个/最后一个页签,键盘焦点随选择移动。页签不会拦截这些键与 Alt、Ctrl、Meta 或 Shift 的组合。共用的会话入口位于详情页签条内,在“规则”页签可见;确认删除操作在两种页签下都可用。 “规则”在“运行时间”卡片中以行编辑本地草稿:“重复”是含每周、周一至周五、每天、每 N 小时、每 N 分钟、每 N 秒、仅一次和自定义(cron)的菜单,每周规则增加“星期”行,固定速率选项只修改它所属单位的间隔数量,一次性规则用独立的“日期”和“时间”两行编辑目标,自定义(cron)用一行“Cron 表达式”编辑按所选时区解释的 5 字段表达式。卡片在浏览器中解析该表达式,并在该行下方显示描述它的本地化语句;Host 会拒绝的表达式改为显示本地化校验信息,并在解析通过前阻止保存。已存储的每周规则若星期集合恰好为周一至周五,则选中“周一至周五”选项。“日期”行是只读触发器,以 `YYYY/MM/DD` 显示暂存的日期并展开带本地化星期表头、月份切换和今天标记的月历;草稿与提交文本保持 ISO 形态;“时间”行是只读触发器,显示统一的 24 小时时钟,并展开时、分、秒三列可滚动列表,当前值高亮并在打开时滚动到可见。在月历或时钟上点选即按与其他行相同的分阶段保存写入草稿;方向键在打开的列或网格内移动,Enter 选定当前值,Escape 关闭面板并把焦点还给所在行,被禁用的行无法打开面板。时钟行显示到秒,未改动的行在提交时保留已存储的毫秒值,编辑时间时按整秒精度提交。“时区”使用高度受限的可搜索菜单。顶部无标题区域保留浏览器本地最近选过的 5 个准确时区;首次使用按真实系统时区和 UTC 初始化,而不根据界面语言猜测所在地。系统时区带有标记,其余清单按当前 UTC 偏移、再按规范 IANA 标识排序。可见文案把 `UTC±HH:MM` 放在本地化时区名称之前;IANA 标识仅在内部使用,但可用于搜索。ICU 显示成相同偏移和本地化名称的多个时区只占一行;所有被合并的标识仍可用于搜索,任务已保存的标识也会继续作为选中值,不会被改写。时区清单来自 `Intl.supportedValuesOf('timeZone')`,名称来自 `Intl.DateTimeFormat(..., { timeZoneName: 'longGeneric' })`:两者都是浏览器内置的 ICU/CLDR 数据,不是抓取或产品自维护的翻译表。因此没有需要刷新的爬虫或生成时区资源;第三方语言包只需提供常规的 `time.locale` 值,即可自动获得对应的 ICU 名称,仅需翻译选择器周边的 UI 文案。运行时无法枚举时改用简短的标识候选列表,已存储但不在其中的时区会保留。每天、每周和 cron 保留各自已存储的规则与时区,其频率行仅在时区与宿主系统时区不同时标出时区。一次性 After/At 以记录已存储的时区初始化日期和时间;记录未保存时区时取本设备时区,初始钟表值在该时区下仍表示同一瞬时;更改时区会保留输入的钟表值并改变时点。因选择种类而重新初始化的种类使用已提交发生时点的钟表值和该选项的时区,该时区取记录已存储的时区或本设备时区:每周的星期取自该发生时点在该时区下的星期,cron 则把该发生时点的钟表值写成每天执行的表达式。固定间隔至少为 60 整秒,不受时区影响。一次保存以分阶段的变更提交完整预期记录,并保留 id、原会话、最近一次回执及已保存历史;未来目标选择、重新确定起算时间和未变更规则的无写入语义见 [Schedule](../../schedule/schedule/README.zh.md#use-this-package)。 -行在保存进行中、目录加载中、删除期间以及任务未运行时禁用。仅当本地草稿与已存储任务不同时才出现保存栏:显示“有未保存的修改”及“取消”和“保存修改”,保存期间“保存修改”变为“保存中…”。“取消”恢复已存储的值。仅当草稿没有未保存修改时,目录刷新才会成为显示值,因此未保存的草稿会保留到权威刷新之后。保存失败保留草稿并显示本地化提示;冲突后针对刷新后的记录重试。任务从目录消失时,“删除”会禁用,直到任务重新出现。更新成功后刷新权威目录。更新被拒绝或无法确认与目录读取失败相互区分:“重试”重新加载目录,不将该更新显示为失败。若更新本身无法确认,应在重试前检查任务。切换任务或关闭详情后才到达的响应不会替换当前视图,任何失败都不会回滚宿主已开始的写入。 +行在保存进行中、目录加载中、删除期间以及任务已结束时禁用。仅当本地草稿与已存储任务不同时才出现保存栏:显示“有未保存的修改”及“取消”和“保存修改”,保存期间“保存修改”变为“保存中…”。“取消”恢复已存储的值。仅当草稿没有未保存修改时,目录刷新才会成为显示值,因此未保存的草稿会保留到权威刷新之后。保存失败保留草稿并显示本地化提示;冲突后针对刷新后的记录重试。任务从目录消失时,“删除”会禁用,直到任务重新出现。更新成功后刷新权威目录。更新被拒绝或无法确认与目录读取失败相互区分:“重试”重新加载目录,不将该更新显示为失败。若更新本身无法确认,应在重试前检查任务。切换任务或关闭详情后才到达的响应不会替换当前视图,任何失败都不会回滚宿主已开始的写入。 打开“任务运行记录”时才请求最新的 20 条已保存记录。“加载更早的记录”按追加顺序从新到旧增加一页,不受实际时间回拨影响。该视图中页签条就是整个页头:名称输入框和“下次运行”行只属于“规则”。每条“已保存的任务运行记录”以时钟图标和任务时区下的本地月份名与时钟字段开头(一次性记录使用浏览器时区),并在存在时显示实际保存的提示文本。超过两行的提示文本只显示前两行并提供“展开”,展开后可“收起”;面板宽度变化时,收起状态的提示文本会重新判断是否超过两行。旧回执没有提示文本时,绝不以当前指令代替。 @@ -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 关闭弹层并将焦点返回按钮;点击外部将其关闭。删除最后一条提醒会移除按钮;在此之前,已经打开的弹层保持可展开状态。 @@ -49,7 +49,7 @@ kind: "package-reference" 调用 `schedule_create` 时,创建的任务会以卡片形式渲染在 transcript 中,包含时钟图标、已存储的标题、频率,以及打开该任务右侧栏页签的“打开”按钮。卡片先按该调用持久化的结果 JSON 收窄出任务,并从该结果读取 `title`;当一次在卡片出现之后成功结束的目录读取解析到该任务时,卡片改为跟随目录显示当前名称与频率,若该读取中不含该记录,则以“已删除”代替频率显示。在已存储字段存在之前录制的结果回退为指令首行。仍在运行的调用、创建失败,或结果中没有完整任务时,卡片保留该行,并在结果为单个非空文本块时显示原始结果文本,不提供“打开”操作。卡片不增加 Host 字段,也不改变 Session 日志格式,因此早于该卡片录制的 Session 也会渲染它。 -侧栏的 Session 行会标记有定时任务的会话:当某行处于空闲状态、未归档且其会话至少有一个活动任务时,该行显示时钟标记;归档行该格留空,其实时状态只在悬浮卡片中显示。对存在活动任务的会话长按悬停时,无论该行是否空闲,都会在其已有的悬浮卡片内增加自动化区段,最多列出两个任务(图标、已存储的标题、频率与下次运行文本),还有更多任务被隐藏时显示遗漏行。处于更高优先级状态的行——有待处理交互、活动仍在进行,或有未查看的完成提醒——保留自身状态点且不渲染标记,两者不会同时出现。该标记和悬停区段读取共享的活动与未运行 Host 任务目录,并从中筛选本会话的活动任务;它们不会激活、保留或取消归档任何会话,也不读取 Session 日志。 +侧栏的 Session 行会标记有定时任务的会话:当某行处于空闲状态、未归档且其会话至少有一个活动任务时,该行显示时钟标记;归档行该格留空,其实时状态只在悬浮卡片中显示。对存在活动任务的会话长按悬停时,无论该行是否空闲,都会在其已有的悬浮卡片内增加自动化区段,最多列出两个任务(图标、已存储的标题、频率与下次运行文本),还有更多任务被隐藏时显示遗漏行。处于更高优先级状态的行——有待处理交互、活动仍在进行,或有未查看的完成提醒——保留自身状态点且不渲染标记,两者不会同时出现。该标记和悬停区段读取共享的活动与已结束 Host 任务目录,并从中筛选本会话的活动任务;它们不会激活、保留或取消归档任何会话,也不读取 Session 日志。 ----- @@ -63,7 +63,7 @@ kind: "package-reference"
实现内部细节 — 点击展开 -浏览器入口注册 `schedules` 主面板及对应的侧栏入口、Session 页头右侧功能位中位于 `order: -5` 且紧邻更多操作菜单左侧的 `schedule-catalog` 入口、经由 `ctx.uiConversation.events` 与 ui-chat 的 list seat `conversation.chat.turnTail` 在轮次层级注册的 `schedule_create` transcript 卡片(因此它渲染在该轮收尾的 assistant 文本之后,并在工具调用组折叠时仍然可见)、root 作用域的两个 Session 行 seat `sidebar.session.row.leading` 与 `sidebar.session.row.hover`,以及 kind 为 `scheduleTask` 的右侧栏页类型 `@deepseek-ai/dsh-client-ui-schedule/task`。该类型分两阶段注册:先把其定义注册进 `ctx.sidebarRightTabs`,再按该定义的 id 把正文与 chip 注册进带键 seat `sidebar.right.pane.tab` 与 `sidebar.right.pane.tab.title`。从 Session 入口打开任务时调用 `ctx.sidebarRight.openTab`,传入该入口的 Session id 与所选任务 id,详情因此出现在该入口所属 Session 的右侧栏中;自动化任务页面保留自己的列表并共用同一个详情组件。全局页面、任务页签、行标记与行悬停区段都从同一个 `schedule.catalog` 源读取活动与未运行任务;页头入口保留自己的按 Session `schedule.list` 读取,只返回该 Session 的活动任务。每个面都使用同一套可观察的查询生命周期,并在 `schedule/changed` 或连接重置后刷新。transcript 卡片从该调用持久化的结果 JSON 收窄出创建的任务,而不是依赖 Host 追加字段;该调用自身的工具调用组单元格由 ui-tool 的通用键控视图渲染,且卡片只对产生完整任务的调用出现。该对齐是因果判定而非时钟比较:目录快照带有 `readRequest`(最新被请求的读取序号)与 `readSettled`(产出当前记录的读取序号);Turn 尾部挂载时锚定 `readRequest` 并请求一次读取,仅在 `readSettled` 大于该锚点后才用其保留记录判定,且源把一次提交内的请求合并为单次 `list()`,只把该读取共享给尚未观察到它的调用者。比较浏览器时钟与 Host 时钟无法确立这一顺序。共用的删除回调与注入的 `onUpdateTiming` 保留所选记录的原 Session 绑定,而不使用当前打开的对话。详情保留当前任务的本地草稿、用于比较的权威值以及等待中或失败的保存;保存等待中或失败期间以及删除确认后,属主保留所显示的任务,使目录刷新在丢弃该行时既不能关闭已呈现的失败,也不能关闭陈述删除的详情。保存发出一次携带完整预期 `ScheduleRecord` 和分阶段变更的 `schedule.update` 请求;`onUpdateTiming` 刷新目录,而不把回读失败变成修改失败。 +浏览器入口注册 `schedules` 主面板及对应的侧栏入口、Session 页头右侧功能位中位于 `order: -5` 且紧邻更多操作菜单左侧的 `schedule-catalog` 入口、经由 `ctx.uiConversation.events` 与 ui-chat 的 list seat `conversation.chat.turnTail` 在轮次层级注册的 `schedule_create` transcript 卡片(因此它渲染在该轮收尾的 assistant 文本之后,并在工具调用组折叠时仍然可见)、root 作用域的两个 Session 行 seat `sidebar.session.row.leading` 与 `sidebar.session.row.hover`,以及 kind 为 `scheduleTask` 的右侧栏页类型 `@deepseek-ai/dsh-client-ui-schedule/task`。该类型分两阶段注册:先把其定义注册进 `ctx.sidebarRightTabs`,再按该定义的 id 把正文与 chip 注册进带键 seat `sidebar.right.pane.tab` 与 `sidebar.right.pane.tab.title`。从 Session 入口打开任务时调用 `ctx.sidebarRight.openTab`,传入该入口的 Session id 与所选任务 id,详情因此出现在该入口所属 Session 的右侧栏中;自动化任务页面保留自己的列表并共用同一个详情组件。全局页面、任务页签、行标记与行悬停区段都从同一个 `schedule.catalog` 源读取活动与已结束任务;页头入口保留自己的按 Session `schedule.list` 读取,只返回该 Session 的活动任务。每个面都使用同一套可观察的查询生命周期,并在 `schedule/changed` 或连接重置后刷新。transcript 卡片从该调用持久化的结果 JSON 收窄出创建的任务,而不是依赖 Host 追加字段;该调用自身的工具调用组单元格由 ui-tool 的通用键控视图渲染,且卡片只对产生完整任务的调用出现。该对齐是因果判定而非时钟比较:目录快照带有 `readRequest`(最新被请求的读取序号)与 `readSettled`(产出当前记录的读取序号);Turn 尾部挂载时锚定 `readRequest` 并请求一次读取,仅在 `readSettled` 大于该锚点后才用其保留记录判定,且源把一次提交内的请求合并为单次 `list()`,只把该读取共享给尚未观察到它的调用者。比较浏览器时钟与 Host 时钟无法确立这一顺序。共用的删除回调与注入的 `onUpdateTiming` 保留所选记录的原 Session 绑定,而不使用当前打开的对话。详情保留当前任务的本地草稿、用于比较的权威值以及等待中或失败的保存;保存等待中或失败期间以及删除确认后,属主保留所显示的任务,使目录刷新在丢弃该行时既不能关闭已呈现的失败,也不能关闭陈述删除的详情。保存发出一次携带完整预期 `ScheduleRecord` 和分阶段变更的 `schedule.update` 请求;`onUpdateTiming` 刷新目录,而不把回读失败变成修改失败。 刷新恢复出的任务标签没有导航参数,因为 Sidebar 只持久化标签的布局记录,不持久化打开方传入的内容。因此该页面类型自行保存从 Session 加布局记录 id 到该记录最后显示任务的 Session 和 id 的关联:每个 Session 一个 `dsh.schedule.task-tab.v1.` localStorage 键,按 tab id 一条记录,且从不保存任务内容。正文和 chip 只对没有导航参数的记录读取该条目,在标签出现之后成功的一次读取无法解析时将其删除,且不代替关联声称任务缺失。这样的一次读取无法为该标签给出任务标识时,正文陈述该状态,而不呈现永不结束的加载。 diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx index 893a2ecac1..ccaba8d9d1 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx @@ -119,17 +119,21 @@ 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. + // The Modal restores focus to the element it captured when it opened; the + // trigger can be removed or rebuilt while the confirmation is open, so the + // page points focus at the current control, or the heading when it is gone. useEffect(() => { if (clearConfirm || !clearFocusPending.current) return clearFocusPending.current = false - if (clearTriggerRef.current?.isConnected === true) return - headingRef.current?.focus() + const trigger = clearTriggerRef.current + const target = trigger?.isConnected === true ? trigger : headingRef.current + target?.focus() }, [clearConfirm]) const confirmClearEnded = (): void => { + // The confirmation button stays focusable while the removal runs, so a + // second press has to be ignored here instead of by `disabled`. + if (clearing) return setClearing(true) void onClearEnded().finally(() => { setClearing(false) @@ -276,7 +280,11 @@ export function TaskManagerPage(props: TaskManagerPageProps) { - } 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 0ae01e8ea6..eec5c8b1c3 100644 --- a/packages/client/ui-schedule/src/client/task-manager-locales.ts +++ b/packages/client/ui-schedule/src/client/task-manager-locales.ts @@ -134,21 +134,21 @@ export const en = { 'delete.cancel': 'Cancel', 'delete.close': 'Close deletion confirmation', 'delete.pending': 'Deleting…', - 'clear.action': 'Clear ended', - 'clear.title': 'Clear ended automation tasks?', - 'clear.description': 'Each task stops triggering and is deleted together with its saved delivery records. ' + 'clear.action': 'Clear inactive', + 'clear.title': 'Clear inactive automation tasks?', + 'clear.description': 'Each task is deleted together with its saved delivery records. ' + 'The original sessions and their messages remain.', - 'clear.count.one': '{count} ended task', - 'clear.count.other': '{count} ended tasks', + 'clear.count.one': '{count} inactive task', + 'clear.count.other': '{count} inactive tasks', 'clear.confirm': 'Clear tasks', 'clear.pending': 'Clearing…', 'clear.close': 'Close clear confirmation', 'toast.deleted': 'Task deleted.', 'toast.deleteFailed': 'Could not delete the task.', - '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 every ended task.', + 'toast.cleared.one': 'Cleared {count} inactive task.', + 'toast.cleared.other': 'Cleared {count} inactive tasks.', + 'toast.clearedNone': 'No inactive tasks to clear.', + 'toast.clearFailed': 'Could not clear every inactive task.', 'card.open': 'Open', 'card.openLabel': 'Open task details: {title}', 'card.deleted': 'Deleted', @@ -291,7 +291,7 @@ export const zh: Record = { 'delete.pending': '正在删除…', 'clear.action': '清理已结束', 'clear.title': '清理已结束的自动化任务?', - 'clear.description': '每个任务将停止触发,并连同其保存的运行记录一并删除。原会话及其消息仍然保留', + 'clear.description': '每个任务连同其保存的运行记录一并删除。原会话及其消息仍然保留', 'clear.count.one': '共 {count} 个已结束任务', 'clear.count.other': '共 {count} 个已结束任务', 'clear.confirm': '确认清理', diff --git a/packages/client/ui-schedule/tests/delete-toast.client.spec.tsx b/packages/client/ui-schedule/tests/delete-toast.client.spec.tsx index b7923087ad..7112670eea 100644 --- a/packages/client/ui-schedule/tests/delete-toast.client.spec.tsx +++ b/packages/client/ui-schedule/tests/delete-toast.client.spec.tsx @@ -80,7 +80,7 @@ describe('ScheduleDeleteToast', () => { expect(screen.getByRole('alert').textContent).toBe(zh['toast.deleteFailed']) }) - it('states how many ended tasks a bulk removal cleared, singular for one', () => { + it('states how many inactive tasks a bulk removal cleared, singular for one', () => { vi.useFakeTimers() const many = mount({ kind: 'cleared', count: 4, seq: 3 }) expect(screen.getByRole('alert').textContent).toBe(en['toast.cleared.other'].replace('{count}', '4')) 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 a8dd653c9d..c30fa5953d 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 @@ -943,7 +943,7 @@ describe('Task manager catalog', () => { }) }) -describe('clearing ended tasks', () => { +describe('clearing inactive tasks', () => { // A distinct identity: the module's `ended` case reuses the active row's id. const endedTask: ScheduleCatalogEntry = { ...ended, id: 'task-ended' as ScheduleId, title: 'Ended summary', prompt: 'Ended summary', @@ -999,12 +999,21 @@ describe('clearing ended tasks', () => { trigger.focus() fireEvent.click(trigger) const dialog = screen.getByRole('dialog', { name: en['clear.title'] }) - fireEvent.click(within(dialog).getByRole('button', { name: en['clear.confirm'] })) + const confirm = within(dialog).getByRole('button', { name: en['clear.confirm'] }) + confirm.focus() + fireEvent.click(confirm) expect(onClearEnded).toHaveBeenCalledOnce() const running = screen.getByRole('dialog', { name: en['clear.title'] }) - expect(within(running).getByRole('button', { name: en['clear.pending'] }).hasAttribute('disabled')).toBe(true) + const runningButton = within(running).getByRole('button', { name: en['clear.pending'] }) + // The pressed control keeps the focus and reads as busy; disabling it would + // hand focus to the document body mid-removal. + expect(runningButton.getAttribute('aria-disabled')).toBe('true') + expect(document.activeElement).toBe(runningButton) expect(within(running).getByRole('button', { name: en['delete.cancel'] }).hasAttribute('disabled')).toBe(true) + // A second press while the removal runs must not start another request. + fireEvent.click(runningButton) + expect(onClearEnded).toHaveBeenCalledOnce() // The confirmation is the only place that states the removal still runs, so // the Escape that would close it waits for the outcome. fireEvent.keyDown(running, { key: 'Escape' }) @@ -1046,6 +1055,19 @@ describe('clearing ended tasks', () => { expect(document.activeElement).toBe(screen.getByRole('heading', { level: 1 })) }) + it('focuses the rebuilt trigger when the dialog closes', () => { + const h = mount({ records: [at, endedTask] }) + fireEvent.click(screen.getByRole('button', { name: en['clear.action'] })) + // Another client clears the ended rows, then a delivery ends one more while + // the confirmation stays open: the trigger unmounts and a new one mounts. + act(() => { h.update({ records: [at] }) }) + act(() => { h.update({ records: [at, anotherEnded] }) }) + const rebuilt = screen.getByRole('button', { name: en['clear.action'] }) + fireEvent.keyDown(screen.getByRole('dialog', { name: en['clear.title'] }), { key: 'Escape' }) + expect(screen.queryByRole('dialog', { name: en['clear.title'] })).toBeNull() + expect(document.activeElement).toBe(rebuilt) + }) + 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' })) diff --git a/packages/schedule/schedule/src/index.ts b/packages/schedule/schedule/src/index.ts index 52cdcac68c..a6b83d3f99 100644 --- a/packages/schedule/schedule/src/index.ts +++ b/packages/schedule/schedule/src/index.ts @@ -384,23 +384,7 @@ export class ScheduleService extends TypertRemoteService { const ended = [...tasks.entries()] .filter(([, task]) => task.status === 'inactive') .map(([, task]) => task.record.id) - let deleted = 0 - let failure: Error | undefined - for (const id of ended) { - try { - // 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 - // of the ended rows behind. A rejection reason that carries no Error is - // wrapped before the first one is kept. - failure ??= error instanceof Error ? error : new Error(`schedule ended-task removal failed: ${String(error)}`) - } - } - if (deleted > 0) this.emitChanged() - if (failure !== undefined) throw failure - return { deleted } + return { deleted: await this.removeRows(ended, 'schedule ended-task removal failed', false) } }) } @@ -500,32 +484,47 @@ export class ScheduleService extends TypertRemoteService { .filter(([, task]) => task.sessionId === sessionId && task.status === 'active') .map(([, task]) => task.record.id) if (active.length === 0) return - // One row per write: the domain table has no batch delete, and each row is - // durable on its own, so a failure partway through leaves the rows already - // removed committed. Observers hear about that change before the failure - // reaches the caller, which still receives it. - let removed = false - let failure: Error | undefined - for (const id of active) { - try { - await tasks.delete(id) - removed = true - } catch (error: unknown) { - // Every row is attempted: the archive has already landed, so a row this - // stop skipped would stay armed in a Session whose runtime then refuses - // its model steps. A rejection reason that carries no Error still has to - // report a failure, so it is wrapped before the first one is kept. - failure ??= error instanceof Error ? error : new Error(`schedule stop failed: ${String(error)}`) - } - } - if (removed) { - this.emitChanged() - this.runtime?.requestDrive() - } - if (failure !== undefined) throw failure + await this.removeRows(active, 'schedule stop failed', true) }) } + /** + * Remove rows one at a time inside the current queue slot. + * + * One row per write: the domain table has no batch delete, and each row is + * durable on its own, so a failure partway through leaves the rows already + * removed committed. Observers hear about that change before the failure + * reaches the caller, which still receives it. Every row is attempted after a + * failure, so one unreadable delete does not leave the rest of the set behind; + * a rejection reason that carries no Error is wrapped before the first one is + * kept. + * @param ids - rows to remove. + * @param label - failure message prefix naming the operation that owned them. + * @param rearm - whether the rows were armed, so the pending timer must be recomputed. + * @returns the number of rows this call removed. + */ + private async removeRows(ids: readonly ScheduleRecord['id'][], label: string, rearm: boolean): Promise { + const tasks = (await this.getDomain()).table('tasks') + let removed = 0 + let failure: Error | undefined + for (const id of ids) { + try { + // Only a row this call removed is counted. The domain answers false when + // the row is absent; that is defensive here because every task write + // shares this queue slot. + if (await tasks.delete(id)) removed += 1 + } catch (error: unknown) { + failure ??= error instanceof Error ? error : new Error(`${label}: ${String(error)}`) + } + } + if (removed > 0) { + this.emitChanged() + if (rearm) this.runtime?.requestDrive() + } + if (failure !== undefined) throw failure + return removed + } + private serialize(work: () => Promise): Promise { if (this.stopping) return Promise.reject(new Error('Schedule service is stopping')) const pending = this.chain.then(work) From ac4f99652e737d5b2f07864dfb1b9b6106f339ff Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 20 Oct 2026 19:27:38 +0800 Subject: [PATCH 05/85] fix(schedule): keep confirmation focus while its detail closes --- apps/web/tests/schedule-after.e2e.ts | 5 +++++ .../ui-schedule/src/client/TaskManagerPage.tsx | 6 ++++-- .../tests/task-manager-page.client.spec.tsx | 17 +++++++++++++++++ packages/schedule/schedule/README.i18n.yaml | 4 ++-- packages/schedule/schedule/README.md | 2 +- packages/schedule/schedule/README.zh.md | 2 +- 6 files changed, 30 insertions(+), 6 deletions(-) diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 8ac84949c9..32fdd747ee 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -1317,6 +1317,11 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { expect(remaining.some(task => endedIds.includes(task.id))).toBe(false) expect(remaining.map(task => task.title)).toContain('Keep this running task') await page.getByText('Cleared 2 inactive tasks.', { exact: true }).waitFor({ timeout: 15_000 }) + // The trigger left with the rows it removed, so the page heading owns the + // focus after the confirmation closes (a browser-level check: jsdom does + // not move focus to the document body when a focused control unmounts). + await expect.poll(() => page.evaluate(() => document.activeElement?.tagName), { timeout: 15_000 }) + .toBe('H1') // The page follows the acknowledged removal: no ended row or cleanup action // remains, and the running task is still listed. await expect.poll(() => manager.getByRole('button', { name: 'Clear inactive', exact: true }).count(), { timeout: 15_000 }) diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx index ccaba8d9d1..6940f448a8 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx @@ -101,13 +101,15 @@ export function TaskManagerPage(props: TaskManagerPageProps) { useEffect(() => { // The detail focuses its own panel; with nothing selected, focus returns // to the row that opened it, or to the page heading once that row is gone. - if (selectedId !== null) return + // An open confirmation owns the focus until it closes, even when a refresh + // drops the selected task behind it. + if (selectedId !== null || clearConfirm) return if (rowRef.current !== null) { const target = rowRef.current.isConnected ? rowRef.current : headingRef.current target?.focus() rowRef.current = null } - }, [selectedId]) + }, [selectedId, clearConfirm]) useEffect(() => { if (status !== 'ready') return 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 c30fa5953d..23fb019d07 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 @@ -1041,6 +1041,23 @@ describe('clearing inactive tasks', () => { expect(document.activeElement).toBe(screen.getByRole('heading', { level: 1 })) }) + it('keeps focus in the confirmation when the selected task disappears behind it', () => { + const h = mount({ records: [at, endedTask] }) + fireEvent.click(screen.getByRole('button', { name: 'Ended summary' })) + expect(screen.getByRole('complementary', { name: en['detail.label'] })).toBeDefined() + fireEvent.click(screen.getByRole('button', { name: en['clear.action'] })) + const dialog = screen.getByRole('dialog', { name: en['clear.title'] }) + // A refresh drops the selected task while the confirmation is open; closing + // the detail must not take the focus out of the dialog. + act(() => { h.update({ records: [at] }) }) + expect(screen.queryByRole('complementary', { name: en['detail.label'] })).toBeNull() + expect(dialog.contains(document.activeElement)).toBe(true) + fireEvent.keyDown(dialog, { key: 'Escape' }) + expect(screen.queryByRole('dialog', { name: en['clear.title'] })).toBeNull() + // The row that held the focus is gone with the detail, so the heading takes it. + 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'] }) diff --git a/packages/schedule/schedule/README.i18n.yaml b/packages/schedule/schedule/README.i18n.yaml index 373a697408..974438a04a 100644 --- a/packages/schedule/schedule/README.i18n.yaml +++ b/packages/schedule/schedule/README.i18n.yaml @@ -15,8 +15,8 @@ en: 23386452a66b2fbd zh: 4a8d91b2f313fead /deepseek-ai-dsh-schedule/use-this-package: - en: 694ff524482a3ac0 - zh: 139e1814a270421c + en: e3d0f91b86885185 + zh: 06f34ec76ff4f57a /deepseek-ai-dsh-schedule/understand-the-implementation: en: 02848e6fd6317b2a zh: 1807a423210f81ef diff --git a/packages/schedule/schedule/README.md b/packages/schedule/schedule/README.md index 0fe1d65bb7..75703af3c5 100644 --- a/packages/schedule/schedule/README.md +++ b/packages/schedule/schedule/README.md @@ -44,7 +44,7 @@ Weekly input adds `weekdays`, a non-empty set of ISO weekday numbers from Monday Cron input carries `expression` and `time_zone`. The expression is the standard five-field Vixie form `minute hour day-of-month month day-of-week`: minute 0-59, hour 0-23, day-of-month 1-31, month 1-12, and day-of-week 0-7 where both `0` and `7` mean Sunday. Every field accepts `*`, a single value, an `a-b` range, a `*/n` or `a-b/n` step with `n >= 1`, and a comma-separated list of those forms. `L`, `W`, `#`, `JAN`/`MON` names, `@daily`-style macros, six-field expressions, out-of-range values, inverted ranges, zero steps, and empty fields are rejected with `invalid_rule` and a message naming the offending field. Because the dialect has five fields, the smallest interval is one minute. Creation stores a canonical expression: repeated values collapse, adjacent values and ranges merge, a uniform step is written as `a-b/n` or, for a field that started with `*`, as a star-step (`*` for every value, otherwise the widest star-step walk plus any remaining values), a step of `1` is dropped, and Sunday is written as `0`. A field that did not start with `*` never becomes a star-step, so the day rule below survives storage. The durable decoder rejects a non-canonical stored expression, so the record always holds the canonical text. When either day-of-month or day-of-week is a star, a local date matches only when both fields match; when neither is a star, either field matching is enough. A field is a star when its text starts with `*`, independently of the values it matches, so a stepped star constrains alongside the other field instead of substituting for it. The first target is the first strictly future instant whose local date and time in that zone match, using the same gap skip and earlier-overlap rules as Daily. See [cron timing](../../../docs/subsystems/schedule.md#cron-wall-clock-input) for the full dialect and canonicalization rules. -A reminder is bound to the calling Agent's Session. The shared `schedule` Remote namespace exposes `catalog` for active and inactive Host tasks with their original Session ids, and `list`, `history`, `update`, and `delete` with an explicit Session id. It also exposes `deleteInactive`, which removes every ended task across Sessions and answers how many rows it removed; an ended task never arms a timer, so that removal recomputes no target, and because rows are removed one at a time a failure partway through leaves the earlier removals committed and rejects instead of reporting a count. Remote `list` and model `schedule_list` return only active tasks. Catalog entries retain only the latest receipt in `lastDelivery`; catalog and list responses omit saved delivery history. None of these operations activates an Agent or reads Session logs. Explicit deletion stops future delivery, leaves the original Session and already queued messages intact, and removes the stored task row together with its saved delivery records: the task leaves `list` and `catalog`, never schedules again, and `history` answers `schedule_not_found` for the same `(sessionId, id)`. +A reminder is bound to the calling Agent's Session. The shared `schedule` Remote namespace exposes `catalog` for active and inactive Host tasks with their original Session ids, and `list`, `history`, `update`, and `delete` with an explicit Session id. It also exposes `deleteInactive`, which removes every inactive task across Sessions and answers how many rows it removed; an inactive task never arms a timer, so that removal recomputes no target, and because rows are removed one at a time a failure partway through leaves the earlier removals committed and rejects instead of reporting a count. Remote `list` and model `schedule_list` return only active tasks. Catalog entries retain only the latest receipt in `lastDelivery`; catalog and list responses omit saved delivery history. None of these operations activates an Agent or reads Session logs. Explicit deletion stops future delivery, leaves the original Session and already queued messages intact, and removes the stored task row together with its saved delivery records: the task leaves `list` and `catalog`, never schedules again, and `history` answers `schedule_not_found` for the same `(sessionId, id)`. Archiving a Session with active reminders is refused until they stop, and choosing to stop them deletes every active reminder; unarchiving does not bring them back. diff --git a/packages/schedule/schedule/README.zh.md b/packages/schedule/schedule/README.zh.md index 5f938af66d..b7dfc48719 100644 --- a/packages/schedule/schedule/README.zh.md +++ b/packages/schedule/schedule/README.zh.md @@ -44,7 +44,7 @@ Schedule 将一次性、固定周期、按每日、按每周以及 cron 本地 cron 输入携带 `expression` 和 `time_zone`。表达式是标准五字段 Vixie 形式 `minute hour day-of-month month day-of-week`:minute 0-59、hour 0-23、day-of-month 1-31、month 1-12、day-of-week 0-7,其中 `0` 和 `7` 都表示周日。每个字段接受 `*`、单个值、`a-b` 范围、`n >= 1` 的 `*/n` 与 `a-b/n` 步长,以及由这些形式组成的逗号分隔列表。`L`、`W`、`#`、`JAN`/`MON` 名称、`@daily` 风格宏、六字段表达式、超出范围的值、反向范围、零步长和空字段都会以 `invalid_rule` 拒绝,且错误信息会指出违规字段。由于该方言只有五个字段,最小间隔为一分钟。创建时存储规范化表达式:重复值合并,相邻的值与范围合并,均匀步长写为 `a-b/n`,而以 `*` 开头的字段写为星号步长(匹配全部取值时写 `*`,否则写最宽的星号步长并追加其余取值),步长 `1` 被移除,周日统一写为 `0`。不以 `*` 开头的字段绝不会变成星号步长,因此下文的日规则在存储后保持不变。持久化解码器拒绝非规范化的已存储表达式,因此记录始终保存规范化文本。day-of-month 或 day-of-week 中任一为星号字段时,本地日期需两个字段都匹配;两者都不是星号字段时,匹配任一字段即可。字段文本以 `*` 开头即为星号字段,与它匹配的取值集合无关,因此步长星号字段与其他字段同时约束,而不是替代它。首个目标是该时区内本地日期与时间匹配的第一个严格未来时点,使用与 Daily 相同的缺口跳过与重叠取较早时点规则。完整方言与规范化规则见 [cron 时间语义](../../../docs/subsystems/schedule.zh.md#cron-wall-clock-input)。 -提醒绑定到调用 Agent 的会话。共享的 `schedule` Remote namespace 提供 `catalog`,返回活动和未运行的宿主任务及其原始会话 id,并提供带明确会话 id 的 `list`、`history`、`update` 和 `delete`。它还提供 `deleteInactive`,删除所有会话中全部未运行任务并返回移除的行数;未运行任务从不会安排定时器,因此该删除不重算任何目标,且由于逐行移除,中途失败时先前已移除的行保持已移除,调用以拒绝结束而不返回计数。Remote `list` 和模型 `schedule_list` 仅返回活动任务。目录条目的 `lastDelivery` 仅保留最近一次回执;目录和列表响应均不包含已保存的发送历史。这些操作均不激活 Agent 或读取会话日志。显式删除会阻止后续投递,保留原会话和已经入队的消息,并移除已存储的任务行及其已保存的发送记录:任务从 `list` 和 `catalog` 中消失、不再调度,`history` 对相同的 `(sessionId, id)` 返回 `schedule_not_found`。 +提醒绑定到调用 Agent 的会话。共享的 `schedule` Remote namespace 提供 `catalog`,返回活动和未运行的宿主任务及其原始会话 id,并提供带明确会话 id 的 `list`、`history`、`update` 和 `delete`。它还提供 `deleteInactive`,删除所有会话中全部未运行(界面显示为「已结束」)任务并返回移除的行数;未运行任务从不会安排定时器,因此该删除不重算任何目标,且由于逐行移除,中途失败时先前已移除的行保持已移除,调用以拒绝结束而不返回计数。Remote `list` 和模型 `schedule_list` 仅返回活动任务。目录条目的 `lastDelivery` 仅保留最近一次回执;目录和列表响应均不包含已保存的发送历史。这些操作均不激活 Agent 或读取会话日志。显式删除会阻止后续投递,保留原会话和已经入队的消息,并移除已存储的任务行及其已保存的发送记录:任务从 `list` 和 `catalog` 中消失、不再调度,`history` 对相同的 `(sessionId, id)` 返回 `schedule_not_found`。 归档仍有活动提醒的会话会被拒绝,直到这些提醒停止;选择停止它们会删除全部活动提醒,取消归档不会把它们带回来。 From 8d0540af50ddfb53f40de010d58fff6730f81f53 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 20 Oct 2026 19:47:46 +0800 Subject: [PATCH 06/85] fix(schedule): hand focus to the heading when a late readback lands --- apps/web/tests/schedule-after.e2e.ts | 4 +-- .../src/client/TaskManagerPage.tsx | 29 +++++++++++++++---- .../tests/task-manager-page.client.spec.tsx | 18 ++++++++++++ 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 32fdd747ee..aa089709c3 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -1318,8 +1318,8 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { expect(remaining.map(task => task.title)).toContain('Keep this running task') await page.getByText('Cleared 2 inactive tasks.', { exact: true }).waitFor({ timeout: 15_000 }) // The trigger left with the rows it removed, so the page heading owns the - // focus after the confirmation closes (a browser-level check: jsdom does - // not move focus to the document body when a focused control unmounts). + // focus after the confirmation closes; this confirms the heading fallback + // in a real browser. await expect.poll(() => page.evaluate(() => document.activeElement?.tagName), { timeout: 15_000 }) .toBe('H1') // The page follows the acknowledged removal: no ended row or cleanup action diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx index 6940f448a8..0d38531291 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx @@ -70,6 +70,9 @@ export function TaskManagerPage(props: TaskManagerPageProps) { // 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) + // Set when the removal settles: a readback superseded by `schedule/changed` + // can publish its rows after the dialog closed, removing the trigger then. + const clearSettled = 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. @@ -124,13 +127,26 @@ export function TaskManagerPage(props: TaskManagerPageProps) { // The Modal restores focus to the element it captured when it opened; the // trigger can be removed or rebuilt while the confirmation is open, so the // page points focus at the current control, or the heading when it is gone. + // A settled removal whose readback lands later unmounts the trigger after the + // dialog closed, which drops focus to the document body; that handoff is + // caught by the same effect watching the ended count. useEffect(() => { - if (clearConfirm || !clearFocusPending.current) return - clearFocusPending.current = false - const trigger = clearTriggerRef.current - const target = trigger?.isConnected === true ? trigger : headingRef.current - target?.focus() - }, [clearConfirm]) + if (clearConfirm) return + if (clearFocusPending.current) { + clearFocusPending.current = false + const trigger = clearTriggerRef.current + if (trigger?.isConnected === true) { + trigger.focus() + return + } + clearSettled.current = false + headingRef.current?.focus() + return + } + if (!clearSettled.current || endedCount > 0 || document.activeElement !== document.body) return + clearSettled.current = false + headingRef.current?.focus() + }, [clearConfirm, endedCount]) const confirmClearEnded = (): void => { // The confirmation button stays focusable while the removal runs, so a @@ -138,6 +154,7 @@ export function TaskManagerPage(props: TaskManagerPageProps) { if (clearing) return setClearing(true) void onClearEnded().finally(() => { + clearSettled.current = true setClearing(false) setClearConfirm(false) }) 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 23fb019d07..ae5eaae4ca 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 @@ -1085,6 +1085,24 @@ describe('clearing inactive tasks', () => { expect(document.activeElement).toBe(rebuilt) }) + it('moves focus to the heading when a late readback removes the trigger', async () => { + const pending = Promise.withResolvers() + 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 removal settles while the catalog still lists the ended row: the + // dialog closes and focus returns to the trigger. + await act(async () => { pending.resolve(undefined) }) + expect(document.activeElement).toBe(trigger) + // A readback superseded by `schedule/changed` publishes afterwards and takes + // the trigger with its row; focus must not stay on the document body. + act(() => { h.update({ records: [at] }) }) + 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' })) From 3c5ab55d16dada8802e3fd2384ba38b7fc0a31af Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 21 Oct 2026 12:21:11 +0800 Subject: [PATCH 07/85] perf(storage): delete task rows as one durable batch --- docs/subsystems/schedule.i18n.yaml | 4 +- docs/subsystems/schedule.md | 7 +-- docs/subsystems/schedule.zh.md | 7 +-- docs/subsystems/storage.i18n.yaml | 8 +-- docs/subsystems/storage.md | 4 +- docs/subsystems/storage.zh.md | 4 +- .../extensions/tool-cordis/src/api-catalog.ts | 6 +- packages/schedule/schedule/README.i18n.yaml | 4 +- packages/schedule/schedule/README.md | 2 +- packages/schedule/schedule/README.zh.md | 2 +- packages/schedule/schedule/src/index.ts | 43 +++++--------- .../schedule/tests/delete-inactive.spec.ts | 39 ++++++------- packages/schedule/schedule/tests/harness.ts | 5 ++ .../schedule/schedule/tests/plugin.spec.ts | 56 +++++++++---------- .../storage/storage-domain/README.i18n.yaml | 4 +- packages/storage/storage-domain/README.md | 2 +- packages/storage/storage-domain/README.zh.md | 2 +- packages/storage/storage-domain/src/domain.ts | 32 +++++++++++ .../storage-domain/tests/domain.spec.ts | 39 +++++++++++++ .../tests/helpers/memory-backend.ts | 8 +++ .../storage/storage-json/README.i18n.yaml | 4 +- packages/storage/storage-json/README.md | 4 +- packages/storage/storage-json/README.zh.md | 4 +- .../storage-json/src/per-record-unit.ts | 39 ++++++++++++- .../storage/storage-json/src/single-unit.ts | 21 ++++++- .../storage-json/tests/json-backend.spec.ts | 34 +++++++++++ .../storage/storage-sqlite/README.i18n.yaml | 2 +- packages/storage/storage-sqlite/README.zh.md | 2 +- packages/storage/storage-sqlite/src/unit.ts | 30 ++++++++-- .../tests/sqlite-backend.spec.ts | 17 ++++++ packages/storage/storage/src/backend.ts | 16 ++++++ packages/storage/storage/tests/contract.ts | 19 +++++++ .../workspace/tests/workspace.spec.ts | 1 + 33 files changed, 346 insertions(+), 125 deletions(-) diff --git a/docs/subsystems/schedule.i18n.yaml b/docs/subsystems/schedule.i18n.yaml index fdd931c7f2..85cdab2a17 100644 --- a/docs/subsystems/schedule.i18n.yaml +++ b/docs/subsystems/schedule.i18n.yaml @@ -27,8 +27,8 @@ en: 93798ad69f74ddd5 zh: 634126ad8accc356 /host-wide-schedule/web-catalog: - en: 30464765cbe9f81a - zh: eecf1ad0888b182f + en: a4f9de4d4f88f018 + zh: 228a2e8fcc726f4c /host-wide-schedule/timing-edits: en: ce5d2095a3ad2e82 zh: 167b3715f8eaffc1 diff --git a/docs/subsystems/schedule.md b/docs/subsystems/schedule.md index 8a794f8da9..7e1459483d 100644 --- a/docs/subsystems/schedule.md +++ b/docs/subsystems/schedule.md @@ -345,7 +345,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. `schedule.deleteInactive()` removes every ended (inactive) task across Sessions, with the saved delivery records each row carries; on success it answers `{ deleted }` with the count it removed, and a failure partway through rejects after the removals already committed and announced. The Automation tasks page's bulk cleanup uses it. An ended task never arms a timer, so the removal recomputes no target. The payload-free `schedule/changed` event invalidates client lists; reconnecting clients fetch current state again. +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. `schedule.deleteInactive()` removes every ended (inactive) task across Sessions, with the saved delivery records each row carries; on success it answers `{ deleted }` with the count it removed, and a failed batch rejects with nothing removed and nothing announced. The Automation tasks page's bulk cleanup uses it. An ended task never arms a timer, so the removal recomputes no target. The payload-free `schedule/changed` event invalidates client lists; reconnecting clients fetch current state again. The shipped Web composition 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. @@ -522,9 +522,8 @@ async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: Abor * Ended tasks never schedule another delivery, so the removal changes no pending * target and no timer is recomputed. Rows are enumerated and removed in one FIFO * slot, so a delivery ordered behind this call ends its task after the - * enumeration rather than during it. Each removed row is durable on its own: a - * failure partway through leaves the earlier removals committed and announced, - * then rejects rather than reporting a partial count as the whole. + * enumeration rather than during it. The batch is one durable write: a failure + * removes nothing and rejects, so the caller never receives a partial removal. * @returns Count of removed rows; zero when the Host held no ended task. */ @Remote('deleteInactive') async deleteInactive(): Promise diff --git a/docs/subsystems/schedule.zh.md b/docs/subsystems/schedule.zh.md index 10356c08d2..d11c1eec8f 100644 --- a/docs/subsystems/schedule.zh.md +++ b/docs/subsystems/schedule.zh.md @@ -345,7 +345,7 @@ type ScheduleCatalogEntry = ScheduleRecord & { } ``` -Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。`schedule.deleteInactive()` 删除跨会话的全部未运行(已结束)任务,连同每行携带的已保存的发送记录;成功时以它移除的计数答复 `{ deleted }`,中途失败时先提交并通知已移除的行,再以拒绝结束。自动化任务页面的批量清理使用该方法。未运行任务从不会安排定时器,因此该删除不重算任何目标。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。 +Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。`schedule.deleteInactive()` 删除跨会话的全部未运行(已结束)任务,连同每行携带的已保存的发送记录;成功时以它移除的计数答复 `{ deleted }`;该批删除是一次持久写入,失败时不删除任何行,也不发通知,调用以拒绝结束。自动化任务页面的批量清理使用该方法。未运行任务从不会安排定时器,因此该删除不重算任何目标。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。 随发行版交付的 Web 组合把 `ui-schedule` 与宿主能力一起挂载。[客户端包](../../packages/client/ui-schedule/README.zh.md) 负责目录、空状态与删除控件。页面单独筛选全部、活动和未运行任务,保留未运行任务详情及详情页签条内的原 Session 入口,并要求显式确认删除。“规则”和“任务运行记录”页签将任务设置与按需分页加载的已保存回执分开。回执与未运行状态均不确认模型执行。 @@ -522,9 +522,8 @@ async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: Abor * Ended tasks never schedule another delivery, so the removal changes no pending * target and no timer is recomputed. Rows are enumerated and removed in one FIFO * slot, so a delivery ordered behind this call ends its task after the - * enumeration rather than during it. Each removed row is durable on its own: a - * failure partway through leaves the earlier removals committed and announced, - * then rejects rather than reporting a partial count as the whole. + * enumeration rather than during it. The batch is one durable write: a failure + * removes nothing and rejects, so the caller never receives a partial removal. * @returns Count of removed rows; zero when the Host held no ended task. */ @Remote('deleteInactive') async deleteInactive(): Promise diff --git a/docs/subsystems/storage.i18n.yaml b/docs/subsystems/storage.i18n.yaml index 632e0788df..2c1b8df95a 100644 --- a/docs/subsystems/storage.i18n.yaml +++ b/docs/subsystems/storage.i18n.yaml @@ -9,14 +9,14 @@ en: b11e06e7c0e0fd13 zh: 4585f26ff416cb41 /storage/the-backend-contract: - en: 2316787902c6a94e - zh: e3bda06e1c33acac + en: 8b8cc57b66f4ff87 + zh: aecf14a900014fc1 /storage/declaring-a-domain: en: 775cce941500f01f zh: 78acbc67f48574cb /storage/the-open-domain: - en: 2468b5df66c49b51 - zh: 1587f460e9769db2 + en: 65b94c4c8d240a77 + zh: 337d9e36a9bce113 /storage/the-domain-facility-ctx-storagedomain: en: deed74fcd4506ef4 zh: 8e3e6e5d2dc2e989 diff --git a/docs/subsystems/storage.md b/docs/subsystems/storage.md index 674221a263..1fcd252e80 100644 --- a/docs/subsystems/storage.md +++ b/docs/subsystems/storage.md @@ -44,7 +44,7 @@ interface StorageBackend { } ``` -A backend owns one medium (a file-tree root, a database file) and exposes optional operation groups; `kv` is the only shipped group. `KvFacet.open(descriptor)` opens one named unit — `KvUnitDescriptor` carries the name, current format version, optional compatible record versions, table names, and whether a global singleton slot exists — and returns a `KvUnit` with `loadAll`, `putRecord`, `deleteRecord`, `setGlobal`, and `close`. Unit and table names must match `UNIT_NAME_RE` (safe as a file name and as a SQL identifier segment); record keys are arbitrary strings that never reach file paths. A unit does not serialize concurrent writes — ordering belongs to the caller — but each single call is atomic on the medium and durable once resolved. A `single` medium stamped with a different version rejects `version-mismatch`; a `per-record` document stamped outside the accepted set reads as absent. A medium that cannot be parsed as the unit rejects `malformed-medium`. [`backend.ts`](../../packages/storage/storage/src/backend.ts) is the normative clause-by-clause contract, and the shared conformance suite in [`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) checks every clause against each backend. The [json backend](../../packages/storage/storage-json/README.md) republishes one whole human-readable file per unit atomically; the [sqlite backend](../../packages/storage/storage-sqlite/README.md) stores one document per row in one database for frequently updated data. +A backend owns one medium (a file-tree root, a database file) and exposes optional operation groups; `kv` is the only shipped group. `KvFacet.open(descriptor)` opens one named unit — `KvUnitDescriptor` carries the name, current format version, optional compatible record versions, table names, and whether a global singleton slot exists — and returns a `KvUnit` with `loadAll`, `putRecord`, `deleteRecord`, `deleteRecords`, `setGlobal`, and `close`. Unit and table names must match `UNIT_NAME_RE` (safe as a file name and as a SQL identifier segment); record keys are arbitrary strings that never reach file paths. A unit does not serialize concurrent writes — ordering belongs to the caller — but each single call is atomic on the medium and durable once resolved. `deleteRecords` applies a whole batch in one call: it resolves once every listed record is durably absent, and a rejection leaves the batch unapplied. A `single` medium stamped with a different version rejects `version-mismatch`; a `per-record` document stamped outside the accepted set reads as absent. A medium that cannot be parsed as the unit rejects `malformed-medium`. [`backend.ts`](../../packages/storage/storage/src/backend.ts) is the normative clause-by-clause contract, and the shared conformance suite in [`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) checks every clause against each backend. The [json backend](../../packages/storage/storage-json/README.md) republishes one whole human-readable file per unit atomically; the [sqlite backend](../../packages/storage/storage-sqlite/README.md) stores one document per row in one database for frequently updated data. ## Declaring a domain @@ -123,7 +123,7 @@ interface Domain { } ``` -Reads are synchronous from authoritative in-memory state: `KvTable` exposes `get`/`entries`/`keys`/`size` (snapshot iterators that stay stable while queued writes land), and the global handle's `get()` serves the spec's `initial` until the first `set` materializes the slot on the medium. Every write — `put`, `delete`, `update`, `global.set` — queues on one per-domain chain and reaches backend durability first, then mutates memory, then emits `domain/changed`; a rejected backend write leaves memory untouched, so reads never diverge from the medium. `update(key, fn)` is an atomic read-modify-write at its chain slot (a missing key rejects `missing-key`); `delete` of an absent key resolves `false` with no write and no event. Returned records are the stored objects themselves, not copies — replace via `put`/`update`, never mutate in place. +Reads are synchronous from authoritative in-memory state: `KvTable` exposes `get`/`entries`/`keys`/`size` (snapshot iterators that stay stable while queued writes land), and the global handle's `get()` serves the spec's `initial` until the first `set` materializes the slot on the medium. Every write — `put`, `delete`, `deleteMany`, `update`, `global.set` — queues on one per-domain chain and reaches backend durability first, then mutates memory, then emits `domain/changed`; a rejected backend write leaves memory untouched, so reads never diverge from the medium. `update(key, fn)` is an atomic read-modify-write at its chain slot (a missing key rejects `missing-key`); `delete` of an absent key resolves `false` with no write and no event. `deleteMany(keys)` removes the present keys as one backend batch in one chain slot, resolves the removed count, and emits one `domain/changed` per removed key; a rejected backend write leaves every record in place and emits nothing. Returned records are the stored objects themselves, not copies — replace via `put`/`update`, never mutate in place. ## The domain facility: `ctx.storageDomain` diff --git a/docs/subsystems/storage.zh.md b/docs/subsystems/storage.zh.md index 2a4366c84e..62afa47835 100644 --- a/docs/subsystems/storage.zh.md +++ b/docs/subsystems/storage.zh.md @@ -44,7 +44,7 @@ interface StorageBackend { } ``` -一个后端拥有一个介质(一棵文件树的根目录、一个数据库文件),并提供可选的操作组;`kv` 是唯一已交付的操作组。`KvFacet.open(descriptor)` 打开一个具名 unit——`KvUnitDescriptor` 携带名称、当前格式版本、可选的兼容记录版本、表名清单,以及是否存在全局单例 slot——并返回提供 `loadAll`、`putRecord`、`deleteRecord`、`setGlobal` 和 `close` 的 `KvUnit`。unit 名与表名必须匹配 `UNIT_NAME_RE`(既可安全用作文件名,也可安全用作 SQL 标识符片段);记录键是任意字符串,绝不进入文件路径。unit 不对并发写入做串行化——顺序由调用方负责——但每次单独调用在介质上都是原子的,且 resolve 后即已持久。`single` 介质上记录的版本不同时拒绝 `version-mismatch`;`per-record` 文档的版本在接受集合之外时读作不存在。无法按该 unit 解析的介质拒绝 `malformed-medium`。[`backend.ts`](../../packages/storage/storage/src/backend.ts) 是逐条款的规范性约定,[`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) 中的共享一致性套件会针对每个后端检查每项条款。[json 后端](../../packages/storage/storage-json/README.zh.md)以原子方式为每个 unit 整文件重新发布一份人类可读文件;[sqlite 后端](../../packages/storage/storage-sqlite/README.zh.md)在单个数据库中每行存储一份文档,用于频繁更新的数据。 +一个后端拥有一个介质(一棵文件树的根目录、一个数据库文件),并提供可选的操作组;`kv` 是唯一已交付的操作组。`KvFacet.open(descriptor)` 打开一个具名 unit——`KvUnitDescriptor` 携带名称、当前格式版本、可选的兼容记录版本、表名清单,以及是否存在全局单例 slot——并返回提供 `loadAll`、`putRecord`、`deleteRecord`、`deleteRecords`、`setGlobal` 和 `close` 的 `KvUnit`。unit 名与表名必须匹配 `UNIT_NAME_RE`(既可安全用作文件名,也可安全用作 SQL 标识符片段);记录键是任意字符串,绝不进入文件路径。unit 不对并发写入做串行化——顺序由调用方负责——但每次单独调用在介质上都是原子的,且 resolve 后即已持久。`deleteRecords` 以一次调用应用整批删除:所有列出的记录都持久缺失后才 resolve,拒绝时整批未应用。`single` 介质上记录的版本不同时拒绝 `version-mismatch`;`per-record` 文档的版本在接受集合之外时读作不存在。无法按该 unit 解析的介质拒绝 `malformed-medium`。[`backend.ts`](../../packages/storage/storage/src/backend.ts) 是逐条款的规范性约定,[`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) 中的共享一致性套件会针对每个后端检查每项条款。[json 后端](../../packages/storage/storage-json/README.zh.md)以原子方式为每个 unit 整文件重新发布一份人类可读文件;[sqlite 后端](../../packages/storage/storage-sqlite/README.zh.md)在单个数据库中每行存储一份文档,用于频繁更新的数据。 ## 声明领域 @@ -123,7 +123,7 @@ interface Domain { } ``` -读取是同步的,来自权威的内存态:`KvTable` 暴露 `get`/`entries`/`keys`/`size`(快照迭代器,在排队写入落地期间保持稳定),global 句柄的 `get()` 在第一次 `set` 将 slot 物化到介质之前一直返回 spec 的 `initial`。每次写入——`put`、`delete`、`update`、`global.set`——都在同一条逐领域写链上排队,先在后端完成持久化,再更新内存,最后发出 `domain/changed`;后端写入被拒时内存原样不动,因此读取绝不会偏离介质。`update(key, fn)` 在其写链 slot 上是一次原子的读-改-写(键缺失时拒绝 `missing-key`);`delete` 一个不存在的键 resolve 为 `false`,不产生写入也不产生事件。返回的记录就是存储的对象本身,不是副本——请经 `put`/`update` 整体替换,绝不要就地修改。 +读取是同步的,来自权威的内存态:`KvTable` 暴露 `get`/`entries`/`keys`/`size`(快照迭代器,在排队写入落地期间保持稳定),global 句柄的 `get()` 在第一次 `set` 将 slot 物化到介质之前一直返回 spec 的 `initial`。每次写入——`put`、`delete`、`deleteMany`、`update`、`global.set`——都在同一条逐领域写链上排队,先在后端完成持久化,再更新内存,最后发出 `domain/changed`;后端写入被拒时内存原样不动,因此读取绝不会偏离介质。`update(key, fn)` 在其写链 slot 上是一次原子的读-改-写(键缺失时拒绝 `missing-key`);`delete` 一个不存在的键 resolve 为 `false`,不产生写入也不产生事件。`deleteMany(keys)` 在一个写链 slot 内把存在的键作为一次后端批量删除移除,resolve 为移除数量,并为每个被移除的键发一次 `domain/changed`;后端写入被拒时所有记录保持原样,也不发事件。返回的记录就是存储的对象本身,不是副本——请经 `put`/`update` 整体替换,绝不要就地修改。 ## 领域 facility:`ctx.storageDomain` diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 8235ff2645..985df528c0 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2202,7 +2202,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: '@Remote(\'deleteInactive\') async deleteInactive(): Promise', - description: 'Delete every ended Host task, leaving active tasks armed.\n\nEnded tasks never schedule another delivery, so the removal changes no pending target and no timer is recomputed. Rows are enumerated and removed in one FIFO slot, so a delivery ordered behind this call ends its task after the enumeration rather than during it. Each removed row is durable on its own: a failure partway through leaves the earlier removals committed and announced, then rejects rather than reporting a partial count as the whole.', + description: 'Delete every ended Host task, leaving active tasks armed.\n\nEnded tasks never schedule another delivery, so the removal changes no pending target and no timer is recomputed. Rows are enumerated and removed in one FIFO slot, so a delivery ordered behind this call ends its task after the enumeration rather than during it. The batch is one durable write: a failure removes nothing and rejects, so the caller never receives a partial removal.', parameters: [], returns: 'Count of removed rows; zero when the Host held no ended task.', }, @@ -6116,11 +6116,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'KvTable', - declaration: 'export interface KvTable {\n get(key: K): V | undefined;\n entries(): IterableIterator<[\n K,\n V\n ]>;\n keys(): IterableIterator;\n readonly size: number;\n put(key: K, value: V): Promise;\n delete(key: K): Promise;\n update(key: K, fn: (current: V) => V): Promise;\n}', + declaration: 'export interface KvTable {\n get(key: K): V | undefined;\n entries(): IterableIterator<[\n K,\n V\n ]>;\n keys(): IterableIterator;\n readonly size: number;\n put(key: K, value: V): Promise;\n delete(key: K): Promise;\n deleteMany(keys: readonly K[]): Promise;\n update(key: K, fn: (current: V) => V): Promise;\n}', }, { name: 'KvUnit', - declaration: 'export interface KvUnit {\n loadAll(): Promise<{\n tables: Record>;\n global: unknown;\n }>;\n putRecord(table: string, key: string, value: unknown): Promise;\n deleteRecord(table: string, key: string): Promise;\n backupRecord?(table: string, key: string): Promise;\n setGlobal(value: unknown): Promise;\n close(): Promise;\n}', + declaration: 'export interface KvUnit {\n loadAll(): Promise<{\n tables: Record>;\n global: unknown;\n }>;\n putRecord(table: string, key: string, value: unknown): Promise;\n deleteRecord(table: string, key: string): Promise;\n deleteRecords(table: string, keys: readonly string[]): Promise;\n backupRecord?(table: string, key: string): Promise;\n setGlobal(value: unknown): Promise;\n close(): Promise;\n}', }, { name: 'KvUnitDescriptor', diff --git a/packages/schedule/schedule/README.i18n.yaml b/packages/schedule/schedule/README.i18n.yaml index 974438a04a..94e9eaafde 100644 --- a/packages/schedule/schedule/README.i18n.yaml +++ b/packages/schedule/schedule/README.i18n.yaml @@ -15,8 +15,8 @@ en: 23386452a66b2fbd zh: 4a8d91b2f313fead /deepseek-ai-dsh-schedule/use-this-package: - en: e3d0f91b86885185 - zh: 06f34ec76ff4f57a + en: a4521f1f145b2dc7 + zh: 690d626671678f93 /deepseek-ai-dsh-schedule/understand-the-implementation: en: 02848e6fd6317b2a zh: 1807a423210f81ef diff --git a/packages/schedule/schedule/README.md b/packages/schedule/schedule/README.md index 75703af3c5..c76e76b6e8 100644 --- a/packages/schedule/schedule/README.md +++ b/packages/schedule/schedule/README.md @@ -44,7 +44,7 @@ Weekly input adds `weekdays`, a non-empty set of ISO weekday numbers from Monday Cron input carries `expression` and `time_zone`. The expression is the standard five-field Vixie form `minute hour day-of-month month day-of-week`: minute 0-59, hour 0-23, day-of-month 1-31, month 1-12, and day-of-week 0-7 where both `0` and `7` mean Sunday. Every field accepts `*`, a single value, an `a-b` range, a `*/n` or `a-b/n` step with `n >= 1`, and a comma-separated list of those forms. `L`, `W`, `#`, `JAN`/`MON` names, `@daily`-style macros, six-field expressions, out-of-range values, inverted ranges, zero steps, and empty fields are rejected with `invalid_rule` and a message naming the offending field. Because the dialect has five fields, the smallest interval is one minute. Creation stores a canonical expression: repeated values collapse, adjacent values and ranges merge, a uniform step is written as `a-b/n` or, for a field that started with `*`, as a star-step (`*` for every value, otherwise the widest star-step walk plus any remaining values), a step of `1` is dropped, and Sunday is written as `0`. A field that did not start with `*` never becomes a star-step, so the day rule below survives storage. The durable decoder rejects a non-canonical stored expression, so the record always holds the canonical text. When either day-of-month or day-of-week is a star, a local date matches only when both fields match; when neither is a star, either field matching is enough. A field is a star when its text starts with `*`, independently of the values it matches, so a stepped star constrains alongside the other field instead of substituting for it. The first target is the first strictly future instant whose local date and time in that zone match, using the same gap skip and earlier-overlap rules as Daily. See [cron timing](../../../docs/subsystems/schedule.md#cron-wall-clock-input) for the full dialect and canonicalization rules. -A reminder is bound to the calling Agent's Session. The shared `schedule` Remote namespace exposes `catalog` for active and inactive Host tasks with their original Session ids, and `list`, `history`, `update`, and `delete` with an explicit Session id. It also exposes `deleteInactive`, which removes every inactive task across Sessions and answers how many rows it removed; an inactive task never arms a timer, so that removal recomputes no target, and because rows are removed one at a time a failure partway through leaves the earlier removals committed and rejects instead of reporting a count. Remote `list` and model `schedule_list` return only active tasks. Catalog entries retain only the latest receipt in `lastDelivery`; catalog and list responses omit saved delivery history. None of these operations activates an Agent or reads Session logs. Explicit deletion stops future delivery, leaves the original Session and already queued messages intact, and removes the stored task row together with its saved delivery records: the task leaves `list` and `catalog`, never schedules again, and `history` answers `schedule_not_found` for the same `(sessionId, id)`. +A reminder is bound to the calling Agent's Session. The shared `schedule` Remote namespace exposes `catalog` for active and inactive Host tasks with their original Session ids, and `list`, `history`, `update`, and `delete` with an explicit Session id. It also exposes `deleteInactive`, which removes every inactive task across Sessions and answers how many rows it removed; an inactive task never arms a timer, so that removal recomputes no target, and the batch is one durable write, so a failure removes nothing and rejects instead of reporting a count. Remote `list` and model `schedule_list` return only active tasks. Catalog entries retain only the latest receipt in `lastDelivery`; catalog and list responses omit saved delivery history. None of these operations activates an Agent or reads Session logs. Explicit deletion stops future delivery, leaves the original Session and already queued messages intact, and removes the stored task row together with its saved delivery records: the task leaves `list` and `catalog`, never schedules again, and `history` answers `schedule_not_found` for the same `(sessionId, id)`. Archiving a Session with active reminders is refused until they stop, and choosing to stop them deletes every active reminder; unarchiving does not bring them back. diff --git a/packages/schedule/schedule/README.zh.md b/packages/schedule/schedule/README.zh.md index b7dfc48719..e1a650358b 100644 --- a/packages/schedule/schedule/README.zh.md +++ b/packages/schedule/schedule/README.zh.md @@ -44,7 +44,7 @@ Schedule 将一次性、固定周期、按每日、按每周以及 cron 本地 cron 输入携带 `expression` 和 `time_zone`。表达式是标准五字段 Vixie 形式 `minute hour day-of-month month day-of-week`:minute 0-59、hour 0-23、day-of-month 1-31、month 1-12、day-of-week 0-7,其中 `0` 和 `7` 都表示周日。每个字段接受 `*`、单个值、`a-b` 范围、`n >= 1` 的 `*/n` 与 `a-b/n` 步长,以及由这些形式组成的逗号分隔列表。`L`、`W`、`#`、`JAN`/`MON` 名称、`@daily` 风格宏、六字段表达式、超出范围的值、反向范围、零步长和空字段都会以 `invalid_rule` 拒绝,且错误信息会指出违规字段。由于该方言只有五个字段,最小间隔为一分钟。创建时存储规范化表达式:重复值合并,相邻的值与范围合并,均匀步长写为 `a-b/n`,而以 `*` 开头的字段写为星号步长(匹配全部取值时写 `*`,否则写最宽的星号步长并追加其余取值),步长 `1` 被移除,周日统一写为 `0`。不以 `*` 开头的字段绝不会变成星号步长,因此下文的日规则在存储后保持不变。持久化解码器拒绝非规范化的已存储表达式,因此记录始终保存规范化文本。day-of-month 或 day-of-week 中任一为星号字段时,本地日期需两个字段都匹配;两者都不是星号字段时,匹配任一字段即可。字段文本以 `*` 开头即为星号字段,与它匹配的取值集合无关,因此步长星号字段与其他字段同时约束,而不是替代它。首个目标是该时区内本地日期与时间匹配的第一个严格未来时点,使用与 Daily 相同的缺口跳过与重叠取较早时点规则。完整方言与规范化规则见 [cron 时间语义](../../../docs/subsystems/schedule.zh.md#cron-wall-clock-input)。 -提醒绑定到调用 Agent 的会话。共享的 `schedule` Remote namespace 提供 `catalog`,返回活动和未运行的宿主任务及其原始会话 id,并提供带明确会话 id 的 `list`、`history`、`update` 和 `delete`。它还提供 `deleteInactive`,删除所有会话中全部未运行(界面显示为「已结束」)任务并返回移除的行数;未运行任务从不会安排定时器,因此该删除不重算任何目标,且由于逐行移除,中途失败时先前已移除的行保持已移除,调用以拒绝结束而不返回计数。Remote `list` 和模型 `schedule_list` 仅返回活动任务。目录条目的 `lastDelivery` 仅保留最近一次回执;目录和列表响应均不包含已保存的发送历史。这些操作均不激活 Agent 或读取会话日志。显式删除会阻止后续投递,保留原会话和已经入队的消息,并移除已存储的任务行及其已保存的发送记录:任务从 `list` 和 `catalog` 中消失、不再调度,`history` 对相同的 `(sessionId, id)` 返回 `schedule_not_found`。 +提醒绑定到调用 Agent 的会话。共享的 `schedule` Remote namespace 提供 `catalog`,返回活动和未运行的宿主任务及其原始会话 id,并提供带明确会话 id 的 `list`、`history`、`update` 和 `delete`。它还提供 `deleteInactive`,删除所有会话中全部未运行(界面显示为「已结束」)任务并返回移除的行数;未运行任务从不会安排定时器,因此该删除不重算任何目标,且该批删除是一次持久写入,失败时不删除任何行,调用以拒绝结束而不返回计数。Remote `list` 和模型 `schedule_list` 仅返回活动任务。目录条目的 `lastDelivery` 仅保留最近一次回执;目录和列表响应均不包含已保存的发送历史。这些操作均不激活 Agent 或读取会话日志。显式删除会阻止后续投递,保留原会话和已经入队的消息,并移除已存储的任务行及其已保存的发送记录:任务从 `list` 和 `catalog` 中消失、不再调度,`history` 对相同的 `(sessionId, id)` 返回 `schedule_not_found`。 归档仍有活动提醒的会话会被拒绝,直到这些提醒停止;选择停止它们会删除全部活动提醒,取消归档不会把它们带回来。 diff --git a/packages/schedule/schedule/src/index.ts b/packages/schedule/schedule/src/index.ts index a6b83d3f99..b5dd3b8617 100644 --- a/packages/schedule/schedule/src/index.ts +++ b/packages/schedule/schedule/src/index.ts @@ -372,9 +372,8 @@ export class ScheduleService extends TypertRemoteService { * Ended tasks never schedule another delivery, so the removal changes no pending * target and no timer is recomputed. Rows are enumerated and removed in one FIFO * slot, so a delivery ordered behind this call ends its task after the - * enumeration rather than during it. Each removed row is durable on its own: a - * failure partway through leaves the earlier removals committed and announced, - * then rejects rather than reporting a partial count as the whole. + * enumeration rather than during it. The batch is one durable write: a failure + * removes nothing and rejects, so the caller never receives a partial removal. * @returns Count of removed rows; zero when the Host held no ended task. */ @Remote('deleteInactive') @@ -489,39 +488,27 @@ export class ScheduleService extends TypertRemoteService { } /** - * Remove rows one at a time inside the current queue slot. + * Remove rows as one durable batch inside the current queue slot. * - * One row per write: the domain table has no batch delete, and each row is - * durable on its own, so a failure partway through leaves the rows already - * removed committed. Observers hear about that change before the failure - * reaches the caller, which still receives it. Every row is attempted after a - * failure, so one unreadable delete does not leave the rest of the set behind; - * a rejection reason that carries no Error is wrapped before the first one is - * kept. + * The batch is one domain write: either every present row is removed and + * announced, or the removal rejects with every row still stored. A rejection + * reason that carries no Error is wrapped with the operation label. * @param ids - rows to remove. * @param label - failure message prefix naming the operation that owned them. * @param rearm - whether the rows were armed, so the pending timer must be recomputed. * @returns the number of rows this call removed. */ private async removeRows(ids: readonly ScheduleRecord['id'][], label: string, rearm: boolean): Promise { - const tasks = (await this.getDomain()).table('tasks') - let removed = 0 - let failure: Error | undefined - for (const id of ids) { - try { - // Only a row this call removed is counted. The domain answers false when - // the row is absent; that is defensive here because every task write - // shares this queue slot. - if (await tasks.delete(id)) removed += 1 - } catch (error: unknown) { - failure ??= error instanceof Error ? error : new Error(`${label}: ${String(error)}`) - } + if (ids.length === 0) return 0 + let removed: number + try { + removed = await (await this.getDomain()).table('tasks').deleteMany(ids) + } catch (error: unknown) { + throw error instanceof Error ? error : new Error(`${label}: ${String(error)}`) } - if (removed > 0) { - this.emitChanged() - if (rearm) this.runtime?.requestDrive() - } - if (failure !== undefined) throw failure + // Rows enumerated in this slot are present, so a non-empty batch always removed rows. + this.emitChanged() + if (rearm) this.runtime?.requestDrive() return removed } diff --git a/packages/schedule/schedule/tests/delete-inactive.spec.ts b/packages/schedule/schedule/tests/delete-inactive.spec.ts index a0913f4608..d1222d010d 100644 --- a/packages/schedule/schedule/tests/delete-inactive.spec.ts +++ b/packages/schedule/schedule/tests/delete-inactive.spec.ts @@ -98,21 +98,14 @@ it('reports zero and announces nothing when the Host holds no ended task', async expect(await service.catalog()).toHaveLength(1) }) -it('keeps the removals that landed, announces them, and reports the failure', async () => { +it('removes nothing and reports the batch failure', async () => { const pool = new MemoryMediaPool() - let deletes = 0 - // The first ended row lands durably; the second row's physical delete rejects. - // Iteration order is a snapshot detail, so the case records which row failed - // instead of assuming which of the two that is. - let failedId: string | undefined + const batches: string[][] = [] const backend = gatedScheduleBackend(pool, { - deleteRecord: async (table, key) => { + deleteRecords: async (table, keys) => { if (table !== 'tasks') return - deletes += 1 - if (deletes === 2) { - failedId = key - throw new Error('injected delete failure') - } + batches.push([...keys]) + throw new Error('injected batch failure') }, }) const test = await harness({ @@ -133,21 +126,21 @@ it('keeps the removals that landed, announces them, and reports the failure', as const failure = await service.deleteInactive().then(() => undefined, (error: unknown) => error) expect(failure).toBeInstanceOf(Error) - expect(String(failure)).toContain('injected delete failure') - // The row removed before the failure is a durable change observers must hear. - expect(changed).toHaveBeenCalledTimes(1) - expect(failedId).toBeDefined() - // The survivor is the row whose delete failed; the other row stayed removed. - expect((await service.catalog()).map(entry => entry.id)).toEqual([failedId]) + expect(String(failure)).toContain('injected batch failure') + // One write carried both rows; the rejection left both stored and announced nothing. + expect(batches).toEqual([['ended-a', 'ended-b']]) + expect(changed).not.toHaveBeenCalled() + expect((await service.catalog()).map(entry => entry.title).sort()) + .toEqual(['Partial first', 'Partial second']) }) -it('attempts every row and reports a rejection reason that carries no Error', async () => { +it('reports a rejection reason that carries no Error as a labeled Error', async () => { const pool = new MemoryMediaPool() - let deletes = 0 + let batches = 0 const backend = gatedScheduleBackend(pool, { - deleteRecord: async (table) => { + deleteRecords: async (table) => { if (table !== 'tasks') return - deletes += 1 + batches += 1 throw undefined }, }) @@ -171,7 +164,7 @@ it('attempts every row and reports a rejection reason that carries no Error', as // A reason that carries no Error must not read as a successful removal. expect(failure).toBeInstanceOf(Error) expect(String(failure)).toContain('schedule ended-task removal failed') - expect(deletes).toBe(2) + expect(batches).toBe(1) expect(changed).not.toHaveBeenCalled() expect((await service.catalog()).map(entry => entry.title).sort()) .toEqual(['Unreportable first', 'Unreportable second']) diff --git a/packages/schedule/schedule/tests/harness.ts b/packages/schedule/schedule/tests/harness.ts index 62c8e33dfa..612c2cd61b 100644 --- a/packages/schedule/schedule/tests/harness.ts +++ b/packages/schedule/schedule/tests/harness.ts @@ -29,6 +29,7 @@ export function gatedScheduleBackend( traps: { putRecord?: (table: string, key: string) => Promise | undefined deleteRecord?: (table: string, key: string) => Promise | undefined + deleteRecords?: (table: string, keys: readonly string[]) => Promise | undefined } = {}, ): StorageBackend { const memory = new MemoryStorageBackend(pool) @@ -49,6 +50,10 @@ export function gatedScheduleBackend( await traps.deleteRecord?.(table, key) await unit.deleteRecord(table, key) }, + deleteRecords: async (table, keys) => { + await traps.deleteRecords?.(table, keys) + await unit.deleteRecords(table, keys) + }, setGlobal: value => unit.setGlobal(value), close: () => unit.close(), ...(backup === undefined ? {} : { backupRecord: backup }), diff --git a/packages/schedule/schedule/tests/plugin.spec.ts b/packages/schedule/schedule/tests/plugin.spec.ts index 9daf1ed31f..a507b5181f 100644 --- a/packages/schedule/schedule/tests/plugin.spec.ts +++ b/packages/schedule/schedule/tests/plugin.spec.ts @@ -171,14 +171,14 @@ describe('shared Schedule management', () => { expect(await service.catalog()).toEqual([]) }) - it('notifies observers when a stop removes part of the set before a delete fails', async () => { + it('reports a stop failure without announcing an unapplied batch', async () => { const pool = new MemoryMediaPool() - let deletes = 0 - // The first row lands durably; the second row's write rejects. + let batches = 0 const backend = gatedScheduleBackend(pool, { - deleteRecord: async (table) => { - deletes += 1 - if (table === 'tasks' && deletes === 2) throw new Error('injected delete failure') + deleteRecords: async (table) => { + if (table !== 'tasks') return + batches += 1 + throw new Error('injected batch failure') }, }) const test = await harness({ pool, backend }) @@ -189,21 +189,25 @@ describe('shared Schedule management', () => { await service.create(sessionId, { prompt: 'second', after_seconds: 60, title: 'Second' }) const changed = vi.fn() ctx.on('schedule/changed', changed) - // The first row is durably gone, so observers must hear about it even though - // the second delete failed, and the caller must still receive that failure. + // The batch was never applied, so observers hear nothing and the caller still + // receives the failure. const failure = await ctx.parallel('workspace/session-stop', { sessionId }) .then(() => undefined, (error: unknown) => error) expect(failure).toBeInstanceOf(AggregateError) - expect(changed).toHaveBeenCalled() - expect((await service.catalog()).map(entry => entry.title)).toEqual(['Second']) + expect(batches).toBe(1) + expect(changed).not.toHaveBeenCalled() + expect((await service.catalog()).map(entry => entry.title).sort()).toEqual(['First', 'Second']) }) it('reports a stop failure whose rejection reason is not an Error', async () => { const pool = new MemoryMediaPool() + let batches = 0 const backend = gatedScheduleBackend(pool, { // A reason that is not an Error is still a rejection the caller must see. - deleteRecord: async (table) => { - if (table === 'tasks') throw undefined + deleteRecords: async (table) => { + if (table !== 'tasks') return + batches += 1 + throw undefined }, }) const test = await harness({ pool, backend }) @@ -218,37 +222,29 @@ describe('shared Schedule management', () => { // Nothing landed, and a reason that carries no Error must not read as success // with the reminder still armed. expect(failure).toBeInstanceOf(AggregateError) + expect(batches).toBe(1) expect(changed).not.toHaveBeenCalled() expect((await service.catalog()).map(entry => entry.title)).toEqual(['First']) }) - it('attempts every row and notifies nothing when the first stop delete fails', async () => { + it('removes the whole stop set in one batch write', async () => { const pool = new MemoryMediaPool() - let deletes = 0 + const batches: string[][] = [] const backend = gatedScheduleBackend(pool, { - deleteRecord: async (table) => { - if (table !== 'tasks') return - deletes += 1 - throw new Error('injected delete failure') + deleteRecords: async (table, keys) => { + if (table === 'tasks') batches.push([...keys]) }, }) const test = await harness({ pool, backend }) tests.push(test) const { ctx, service } = test - const sessionId = SessionId('stop-failed') + const sessionId = SessionId('stop-one-batch') await service.create(sessionId, { prompt: 'first', after_seconds: 60, title: 'First' }) await service.create(sessionId, { prompt: 'second', after_seconds: 60, title: 'Second' }) - const changed = vi.fn() - ctx.on('schedule/changed', changed) - const failure = await ctx.parallel('workspace/session-stop', { sessionId }) - .then(() => undefined, (error: unknown) => error) - // Nothing landed, so nothing is announced; every row was still attempted, - // because a row this stop skipped would stay armed in an archived Session. - expect(failure).toBeInstanceOf(AggregateError) - expect(deletes).toBe(2) - expect(changed).not.toHaveBeenCalled() - // The catalog sorts by target then id, so compare the surviving set, not its order. - expect((await service.catalog()).map(entry => entry.title).sort()).toEqual(['First', 'Second']) + await ctx.parallel('workspace/session-stop', { sessionId }) + expect(batches).toHaveLength(1) + expect(batches[0]).toHaveLength(2) + expect(await service.catalog()).toEqual([]) }) it('catalogs persisted reminders across cold Sessions in target and identity order', async () => { diff --git a/packages/storage/storage-domain/README.i18n.yaml b/packages/storage/storage-domain/README.i18n.yaml index cfcfaac6df..29d392dac9 100644 --- a/packages/storage/storage-domain/README.i18n.yaml +++ b/packages/storage/storage-domain/README.i18n.yaml @@ -36,8 +36,8 @@ en: 1cc8b33834457c38 zh: c549afc7c4960e9e /deepseek-ai-dsh-storage-domain/understand-the-implementation/design-concept: - en: 1e063b13eced114a - zh: f65c910b124fd595 + en: 899fd5b09d9eb3c4 + zh: 771eb56d143cca5b /deepseek-ai-dsh-storage-domain/understand-the-implementation/open-sequence: en: 052da5d6c45205ca zh: 724c875e3c431fee diff --git a/packages/storage/storage-domain/README.md b/packages/storage/storage-domain/README.md index 7869de5671..fc9d206aac 100644 --- a/packages/storage/storage-domain/README.md +++ b/packages/storage/storage-domain/README.md @@ -86,7 +86,7 @@ The domain layer is a single implementation, not an abstracted seam: consumers d - **The spec object is the single source of truth.** `defineDomain` pins the spec's literal types and validates its fields at the owning package's module load, before any medium is touched. Record schemas are zod, so `z.infer` avoids duplicating consumer types; plugin `Config` stays schemastery. - **Memory is authoritative; the medium is the durable projection.** Reads are synchronous from validated in-memory state. Every write queues on one per-domain write chain: backend durability first, then memory mutation, then `domain/changed` — a rejected backend write leaves memory untouched, so reads never diverge from the medium. -- **One write chain per domain.** `put`, `delete`, `update`, and `global.set` all queue on it; `update`'s transform runs at its chain slot, so concurrent updates never interleave. Records are plain immutable data — returned values are the stored objects themselves and must not be mutated in place. +- **One write chain per domain.** `put`, `delete`, `deleteMany`, `update`, and `global.set` all queue on it; `update`'s transform runs at its chain slot, so concurrent updates never interleave. Records are plain immutable data — returned values are the stored objects themselves and must not be mutated in place. - **Writes emit after the commit point.** `domain/changed` is a notification, not a transaction participant: a throwing listener is contained with a logged warning rather than rejecting the already-durable write. ### Open sequence diff --git a/packages/storage/storage-domain/README.zh.md b/packages/storage/storage-domain/README.zh.md index db7cc41dd1..44c89f1769 100644 --- a/packages/storage/storage-domain/README.zh.md +++ b/packages/storage/storage-domain/README.zh.md @@ -86,7 +86,7 @@ domain.table('workspaces').update(id, (r) => ({ ...r, path: newPath })) - **spec 对象是唯一真源。** `defineDomain` 固定 spec 的字面类型,并在所属包的模块加载时、任何介质被触碰之前校验其字段。记录 schema 使用 zod,因此 `z.infer` 可避免重复定义消费方类型;插件 `Config` 仍由 schemastery 负责。 - **内存具有最终决定权;介质是持久投影。** 读取同步取自经过校验的内存状态。每次写入都在每个领域一条的写入链上排队:先到达后端持久状态,再变更内存,然后发出 `domain/changed`——被拒绝的后端写入不会触碰内存,因此读取永远不会与介质分叉。 -- **每个领域一条写入链。** `put`、`delete`、`update` 与 `global.set` 都在其上排队;`update` 的变换在链上自己的槽位运行,因此并发更新绝不会交错。记录是普通不可变数据——返回值就是已存对象本身,绝不能原地修改。 +- **每个领域一条写入链。** `put`、`delete`、`deleteMany`、`update` 与 `global.set` 都在其上排队;`update` 的变换在链上自己的槽位运行,因此并发更新绝不会交错。记录是普通不可变数据——返回值就是已存对象本身,绝不能原地修改。 - **写入在提交点之后发出。** `domain/changed` 是通知,不是事务参与者:监听器抛出异常时,系统会隔离该异常并记录警告,而不会让已经持久的写入被拒绝。 ### 打开顺序 diff --git a/packages/storage/storage-domain/src/domain.ts b/packages/storage/storage-domain/src/domain.ts index c4087d6dd2..be8f6b0298 100644 --- a/packages/storage/storage-domain/src/domain.ts +++ b/packages/storage/storage-domain/src/domain.ts @@ -79,6 +79,18 @@ export interface KvTable { */ delete(key: K): Promise + /** + * Delete several records durably in one write slot. + * + * The batch is one job on the domain's write chain: it runs at its slot, not + * at call time, so an earlier queued write of the same domain is visible to + * it and no other write interleaves. Missing keys are ignored; a backend + * failure rejects with the batch unapplied, leaving every record in place. + * @param keys - Record keys; duplicates are harmless. + * @returns the number of keys that existed and were removed. + */ + deleteMany(keys: readonly K[]): Promise + /** * Atomic read-modify-write on the domain's write chain: `fn` sees the * value current at its queue slot, so concurrent updates never interleave. @@ -329,6 +341,26 @@ class KvTableImpl implements KvTable { }) } + deleteMany(keys: readonly K[]): Promise { + return this.host.enqueue(async () => { + // Existence and the backend write happen at this job's chain slot, so an + // earlier queued put of a key is observed and the batch is one unit call. + const present = [...new Set(keys)].filter(key => this.records.has(key)) + if (present.length === 0) return 0 + await this.host.unit.deleteRecords(this.tableName, present) + for (const key of present) { + this.records.delete(key) + this.host.emitChanged({ + domain: this.host.domainName, + table: this.tableName, + key, + operation: 'deleted', + }) + } + return present.length + }) + } + update(key: K, fn: (current: V) => V): Promise { return this.host.enqueue(async () => { if (!this.records.has(key)) { diff --git a/packages/storage/storage-domain/tests/domain.spec.ts b/packages/storage/storage-domain/tests/domain.spec.ts index bf328d9977..9762682eb2 100644 --- a/packages/storage/storage-domain/tests/domain.spec.ts +++ b/packages/storage/storage-domain/tests/domain.spec.ts @@ -131,6 +131,7 @@ describe('DomainFacility.open', () => { loadAll: async () => ({ tables: {}, global: null }), putRecord: async () => {}, deleteRecord: async () => {}, + deleteRecords: async () => {}, setGlobal: async () => {}, close: async () => {}, }), @@ -275,6 +276,25 @@ describe('KvTable writes', () => { await expect(table.delete('a')).resolves.toBe(false) }) + it('deleteMany removes the existing keys in one write slot and reports the count', async () => { + const { facility, changes } = await harness() + const domain = await facility.open(spec) + const table = domain.table('items') + await table.put('a', { label: 'x', count: 1 }) + await table.put('b', { label: 'y', count: 2 }) + await table.put('c', { label: 'z', count: 3 }) + const seen = changes.length + await expect(table.deleteMany(['a', 'c', 'missing', 'a'])).resolves.toBe(2) + expect([...table.entries()]).toEqual([['b', { label: 'y', count: 2 }]]) + expect(changes.slice(seen)).toEqual([ + { domain: 'demo', table: 'items', key: 'a', operation: 'deleted' }, + { domain: 'demo', table: 'items', key: 'c', operation: 'deleted' }, + ]) + // A batch without existing keys is a no-op: no backend write and no event. + await expect(table.deleteMany(['ghost'])).resolves.toBe(0) + expect(changes).toHaveLength(seen + 2) + }) + it('emits domain/changed per durable write, in order, with tombstones and global marker', async () => { const { facility, changes } = await harness() const domain = await facility.open(spec) @@ -317,6 +337,25 @@ describe('durability failure', () => { expect(table.get('a')).toEqual({ label: 'x', count: 2 }) }) + it('leaves every record in place when a batch delete fails durability', async () => { + const pool = new MemoryMediaPool() + const { facility, changes } = await harness({ pool }) + const table = (await facility.open(spec)).table('items') + await table.put('a', { label: 'x', count: 1 }) + await table.put('b', { label: 'y', count: 2 }) + const seen = changes.length + + pool.failNextWrites = 1 + await expect(table.deleteMany(['a', 'b'])).rejects.toThrow(/injected/) + + expect([...table.entries()]).toEqual([ + ['a', { label: 'x', count: 1 }], + ['b', { label: 'y', count: 2 }], + ]) + expect(pool.media.get('demo')!.tables.get('items')!.get('b')).toEqual({ label: 'y', count: 2 }) + expect(changes).toHaveLength(seen) + }) + it('keeps serving initial when the first global set fails durability', async () => { const pool = new MemoryMediaPool() const { facility } = await harness({ pool }) diff --git a/packages/storage/storage-domain/tests/helpers/memory-backend.ts b/packages/storage/storage-domain/tests/helpers/memory-backend.ts index 3c8547d280..a081fbf355 100644 --- a/packages/storage/storage-domain/tests/helpers/memory-backend.ts +++ b/packages/storage/storage-domain/tests/helpers/memory-backend.ts @@ -96,6 +96,14 @@ class MemoryKvUnit implements KvUnit { this.medium.tables.get(table)?.delete(key) } + async deleteRecords(table: string, keys: readonly string[]): Promise { + this.assertOpen() + this.pool.consumeInjectedFailure() + const records = this.medium.tables.get(table) + if (records === undefined) return + for (const key of new Set(keys)) records.delete(key) + } + async setGlobal(value: unknown): Promise { this.assertOpen() this.pool.consumeInjectedFailure() diff --git a/packages/storage/storage-json/README.i18n.yaml b/packages/storage/storage-json/README.i18n.yaml index 6102e84e8f..3c292a3da1 100644 --- a/packages/storage/storage-json/README.i18n.yaml +++ b/packages/storage/storage-json/README.i18n.yaml @@ -30,8 +30,8 @@ en: b7d222309b3e5438 zh: 807c7314ee341d94 /deepseek-ai-dsh-storage-json/understand-the-implementation/design-concept: - en: a5a4e3cf05597661 - zh: 539ad4b4045ff572 + en: 3139c95cf26c40e2 + zh: 215e88a688869cbe /deepseek-ai-dsh-storage-json/understand-the-implementation/file-formats: en: b3de0a697db4f916 zh: 56b26f7823392bd5 diff --git a/packages/storage/storage-json/README.md b/packages/storage/storage-json/README.md index 3fd03738ee..3d8665bb34 100644 --- a/packages/storage/storage-json/README.md +++ b/packages/storage/storage-json/README.md @@ -69,8 +69,8 @@ The two layouts share atomic publication but assign state ownership differently. ### Design concept -- **`single` keeps memory authoritative.** Each write changes the in-memory unit, serializes its complete state, and atomically replaces `.json`. A failed publish restores the prior in-memory value. -- **`per-record` keeps the directory authoritative.** Each put or delete changes one `//.json` document, and `loadAll()` rereads the tree. Each document stamps the unit version and carries one record value. +- **`single` keeps memory authoritative.** Each write changes the in-memory unit, serializes its complete state, and atomically replaces `.json`. A failed publish restores the prior in-memory value. A batch delete is one such publish: the whole batch is applied or restored together. +- **`per-record` keeps the directory authoritative.** Each put or delete changes one `/
/.json` document, and `loadAll()` rereads the tree. A batch delete renames the batch's documents out of the readable set, restores them if a rename fails, and unlinks the staged copies afterwards. Each document stamps the unit version and carries one record value. - **Publication is durable per call.** A write uses a temporary file, fsync, atomic `rename()` replacement, and a parent-directory fsync on POSIX. The domain layer's write chain supplies ordering across calls. ### File formats diff --git a/packages/storage/storage-json/README.zh.md b/packages/storage/storage-json/README.zh.md index d504694763..d611a65666 100644 --- a/packages/storage/storage-json/README.zh.md +++ b/packages/storage/storage-json/README.zh.md @@ -69,8 +69,8 @@ kind: "package-reference" ### 设计理念 -- **`single` 以内存为权威状态。** 每次写入都会更改内存单元、序列化其完整状态,并以原子方式替换 `.json`。发布失败会恢复先前的内存值。 -- **`per-record` 以目录为权威状态。** 每次 put 或 delete 都会更改一个 `/
/.json` 文档,`loadAll()` 则重新读取目录树。每份文档都带有单元版本戳与一条记录值。 +- **`single` 以内存为权威状态。** 每次写入都会更改内存单元、序列化其完整状态,并以原子方式替换 `.json`。发布失败会恢复先前的内存值。批量删除就是这样一次发布:整批一起应用或一起恢复。 +- **`per-record` 以目录为权威状态。** 每次 put 或 delete 都会更改一个 `/
/.json` 文档,`loadAll()` 则重新读取目录树。每份文档都带有单元版本戳与一条记录值。批量删除先把整批文档改名移出可读集合,任一次改名失败就恢复,之后删除暂存副本。 - **每次调用都持久发布。** 写入过程使用临时文件、fsync、原子 `rename()` 替换,并在 POSIX 上 fsync 父目录。领域层写入链负责安排跨调用的顺序。 ### 文件格式 diff --git a/packages/storage/storage-json/src/per-record-unit.ts b/packages/storage/storage-json/src/per-record-unit.ts index 3d064411ee..b0a86537c5 100644 --- a/packages/storage/storage-json/src/per-record-unit.ts +++ b/packages/storage/storage-json/src/per-record-unit.ts @@ -2,8 +2,9 @@ * One opened JSON unit in `per-record` layout: the unit is a directory at * `dir`, holding one document per record under `/
/.json` * plus `global.json` for the global slot. The directory is the state — this - * unit holds NO in-memory state of its own: `loadAll` re-reads the tree and - * every write is one durable file operation. The domain layer owns the live + * unit holds NO in-memory state of its own: `loadAll` re-reads the tree, each + * put or delete touches one durable document, and a batch delete renames its + * documents out of the readable set before unlinking them. The domain layer owns the live * in-memory tables (seeded by the open-time `loadAll`) and serializes writes * through its write chain, so this unit never mutates memory and needs no * rollback — a failed write simply leaves both the file and the domain's @@ -228,6 +229,40 @@ export class PerRecordJsonUnit implements KvUnit { await this.tracked(rm(join(this.tableDir(table), `${key}.json`), { force: true })) } + /** + * Durably delete many records as one batch. Documents are renamed out of the + * readable set first; only after the whole batch is staged are the staged + * copies unlinked. A failure while staging restores the documents it moved, + * so a rejection leaves the batch unapplied. A staged document a crash leaves + * behind is not readable (`open` reads `*.json` documents only). + */ + async deleteRecords(table: string, keys: readonly string[]): Promise { + this.assertOpen() + const dir = this.tableDir(table) + const unique = [...new Set(keys)] + for (const key of unique) assertSafeKey(this.descriptor.name, key) + const staged: Array<{ from: string; to: string }> = [] + try { + for (const key of unique) { + const from = join(dir, `${key}.json`) + const to = `${from}.deleting` + try { + await this.tracked(rename(from, to)) + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue + throw error + } + staged.push({ from, to }) + } + } catch (error: unknown) { + await Promise.allSettled(staged.map(({ from, to }) => rename(to, from))) + throw error + } + // The renames already removed the batch from the readable set; unlinking is + // cleanup, and a failed unlink leaves an unreadable file rather than an entry. + await Promise.allSettled(staged.map(({ to }) => this.tracked(rm(to, { force: true })))) + } + /** * Move one record's document aside as `.json.bak.`. The * moved file no longer ends in `.json`, so every later read ignores it; the diff --git a/packages/storage/storage-json/src/single-unit.ts b/packages/storage/storage-json/src/single-unit.ts index 085b37cfc0..ed044f61b7 100644 --- a/packages/storage/storage-json/src/single-unit.ts +++ b/packages/storage/storage-json/src/single-unit.ts @@ -1,7 +1,8 @@ /** * One opened JSON unit in `single` layout: the whole unit is one document at * `/.json`. The in-memory state is authoritative; every write - * primitive mutates it and republishes the whole file atomically. Writes are + * primitive mutates it and republishes the whole file atomically, including a + * batch delete, which changes several records but publishes once. Writes are * NOT queued here — per the backend contract, write ordering belongs to the * caller (the domain layer's write chain); this unit only guarantees that * each single call publishes a complete, durable file. The `per-record` @@ -98,6 +99,24 @@ class SingleJsonUnit implements KvUnit { }) } + async deleteRecords(table: string, keys: readonly string[]): Promise { + this.assertOpen() + const records = this.records(table) + const removed = new Map() + for (const key of new Set(keys)) { + if (!records.has(key)) continue + removed.set(key, records.get(key)) + records.delete(key) + } + if (removed.size === 0) return + // One atomic publish applies the whole batch; a failed publish restores + // every removed entry so memory and the medium stay together. + await this.publish().catch((error: unknown) => { + for (const [key, value] of removed) records.set(key, value) + throw error + }) + } + async setGlobal(value: unknown): Promise { this.assertOpen() if (!this.descriptor.hasGlobal) { diff --git a/packages/storage/storage-json/tests/json-backend.spec.ts b/packages/storage/storage-json/tests/json-backend.spec.ts index 0e738131a7..51c5825e35 100644 --- a/packages/storage/storage-json/tests/json-backend.spec.ts +++ b/packages/storage/storage-json/tests/json-backend.spec.ts @@ -94,6 +94,7 @@ describe('json backend specifics', () => { await expect(unit.putRecord('t', 'k', { v: 'rejected' })).rejects.toThrow() await expect(unit.putRecord('t', 'k2', { v: 'also rejected' })).rejects.toThrow() await expect(unit.deleteRecord('t', 'k')).rejects.toThrow() + await expect(unit.deleteRecords('t', ['k'])).rejects.toThrow() await expect(unit.setGlobal({ g: 'rejected' })).rejects.toThrow() await rm(path, { recursive: true }) await rename(backup, path) @@ -285,6 +286,39 @@ describe('per-record layout', () => { await backend.close() }) + it('deletes a batch of documents as one staged set', async () => { + const root = await freshRoot() + const backend = new JsonStorageBackend(root) + const unit = await backend.kv.open(descriptor) + await unit.putRecord('t', 'k1', { v: 1 }) + await unit.putRecord('t', 'k2', { v: 2 }) + await unit.putRecord('t', 'k3', { v: 3 }) + await unit.deleteRecords('t', ['k1', 'k3', 'missing', 'k1']) + expect(await unit.loadAll()).toEqual({ tables: { t: { k2: { v: 2 } } }, global: null }) + // The staged documents are unlinked rather than left in the tree. + expect((await readdir(join(root, 'recs', 't'))).sort()).toEqual(['k2.json']) + await backend.close() + }) + + it('restores a batch when a staged rename fails, and rejects unsafe keys first', async () => { + const root = await freshRoot() + const backend = new JsonStorageBackend(root) + const unit = await backend.kv.open(descriptor) + await unit.putRecord('t', 'k1', { v: 1 }) + await unit.putRecord('t', 'k2', { v: 2 }) + // A non-empty directory at the second staged path makes its rename fail + // after the first document was staged out of the readable set. + const blocked = `${recordPath(root, 'k2')}.deleting` + await mkdir(blocked) + await writeFile(join(blocked, 'occupied'), '', 'utf8') + await expect(unit.deleteRecords('t', ['k1', 'k2'])).rejects.toThrow() + expect(await unit.loadAll()).toEqual({ tables: { t: { k1: { v: 1 }, k2: { v: 2 } } }, global: null }) + await rm(blocked, { recursive: true }) + // An unsafe key rejects before any document is renamed. + await expect(unit.deleteRecords('t', ['ok', 'a/b'])).rejects.toThrow(/not path-safe/) + await backend.close() + }) + it('rejects unsafe keys and undeclared tables, and enforces the closed guard', async () => { const root = await freshRoot() const backend = new JsonStorageBackend(root) diff --git a/packages/storage/storage-sqlite/README.i18n.yaml b/packages/storage/storage-sqlite/README.i18n.yaml index da99dbb2f5..0795b556f9 100644 --- a/packages/storage/storage-sqlite/README.i18n.yaml +++ b/packages/storage/storage-sqlite/README.i18n.yaml @@ -31,7 +31,7 @@ zh: 187d5d61980c8fe7 /deepseek-ai-dsh-storage-sqlite/understand-the-implementation/design-concept: en: 54ee8d9d7746e82d - zh: 2cc9f29ebcc29adc + zh: 510c8bca88d2495c /deepseek-ai-dsh-storage-sqlite/understand-the-implementation/open-sequence: en: 50d85e8d8e8b5bc1 zh: 2a7067bd7fb47b35 diff --git a/packages/storage/storage-sqlite/README.zh.md b/packages/storage/storage-sqlite/README.zh.md index fb15673c73..89743bbd9e 100644 --- a/packages/storage/storage-sqlite/README.zh.md +++ b/packages/storage/storage-sqlite/README.zh.md @@ -69,7 +69,7 @@ kind: "package-reference" ### 设计理念 - **每行一份文档。** 每个单元表都变成一张物理 STRICT 表 `u__
(key TEXT PRIMARY KEY, value TEXT)`,其 `value` 列保存记录的 JSON 文本;全局单例存放在共享的 `unit_globals` 表中。一个键的更新恰好触碰一行——这就是把高频变更领域路由到这里的原因。 -- **单语句原子性。** 每个写入原语都是一条预处理语句,因此 SQLite 的逐语句原子性无需显式事务即可满足 KV 约定;写入顺序仍由调用方负责(领域层的写入链)。 +- **语句原子性。** 每个单记录写入都是一条预处理语句;批量删除在一个显式事务中执行其删除,因此整批一起提交或回滚。写入顺序仍由调用方负责(领域层的写入链)。 - **名称在 DDL 之前校验。** 单元名与表名在进入 DDL 之前必须匹配 `UNIT_NAME_RE`,因此任何外部输入都不会被插值进 SQL 标识符。 - **版本明确报错。** 物理布局版本存放在 `PRAGMA user_version`(全新数据库最后盖戳);单元格式版本存放在 `units` 表中。任何其他已标记值都会被拒绝——不做迁移。 diff --git a/packages/storage/storage-sqlite/src/unit.ts b/packages/storage/storage-sqlite/src/unit.ts index e8c01dc632..f43988cdf4 100644 --- a/packages/storage/storage-sqlite/src/unit.ts +++ b/packages/storage/storage-sqlite/src/unit.ts @@ -1,9 +1,10 @@ /** * One opened SQLite KV unit: prepared per-table statements over the * `u__
` record tables plus this unit's row in the shared - * `unit_globals` table. Each primitive is a single statement, so atomicity - * comes from SQLite itself — no explicit transactions, and no write queue - * (write ordering is the caller's responsibility per the KV contract). + * `unit_globals` table. Each single-record primitive is one statement, so + * SQLite's per-statement atomicity applies; a batch delete runs its deletes in + * one explicit transaction. There is no write queue — write ordering is the + * caller's responsibility per the KV contract. * @module @deepseek-ai/dsh-storage-sqlite/unit */ @@ -36,7 +37,7 @@ export class SqliteKvUnit implements KvUnit { * @param onClose - Backend callback releasing this unit's open-name slot. */ constructor( - db: DatabaseSync, + private readonly db: DatabaseSync, private readonly descriptor: KvUnitDescriptor, private readonly onClose: () => void, ) { @@ -108,6 +109,27 @@ export class SqliteKvUnit implements KvUnit { }) } + deleteRecords(table: string, keys: readonly string[]): Promise { + return this.settle(() => { + const remove = this.statementsFor(table).remove + this.db.exec('BEGIN IMMEDIATE') + try { + for (const key of new Set(keys)) remove.run(key) + this.db.exec('COMMIT') + } catch (error: unknown) { + try { + this.db.exec('ROLLBACK') + } catch (rollbackError: unknown) { + // A failed rollback leaves the connection's state to its own next + // statement; both failures are reported, neither is discarded. + /* v8 ignore next -- ROLLBACK fails only on an unusable connection; the next statement reports it. */ + throw new AggregateError([error, rollbackError], `kv unit '${this.descriptor.name}' batch delete failed and could not roll back`) + } + throw error + } + }) + } + setGlobal(value: unknown): Promise { return this.settle(() => { if (this.globalUpsert === undefined) { diff --git a/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts b/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts index c5f5dd4ee3..795d832d05 100644 --- a/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts +++ b/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts @@ -50,6 +50,23 @@ describe('sqlite backend specifics', () => { await backend.close() }) + it('rolls back a batch delete whose transaction fails', async () => { + const path = await freshDbPath() + const backend = backendAt(path) + const unit = await backend.kv.open(DESCRIPTOR) + await unit.putRecord('records', 'a', { n: 1 }) + await unit.putRecord('records', 'b', { n: 2 }) + const db = new DatabaseSync(path) + db.exec('CREATE TRIGGER block_b BEFORE DELETE ON "u_specimen_records"' + + " WHEN old.key = 'b' BEGIN SELECT RAISE(ABORT, 'blocked'); END") + await expect(unit.deleteRecords('records', ['a', 'b'])).rejects.toThrow(/blocked/) + db.exec('DROP TRIGGER block_b') + db.close() + // The transaction rolled back, so the key deleted before the failing row is back. + expect((await unit.loadAll()).tables['records']).toEqual({ a: { n: 1 }, b: { n: 2 } }) + await backend.close() + }) + it('materializes STRICT record tables and stamps the schema version', async () => { const path = await freshDbPath() const backend = backendAt(path) diff --git a/packages/storage/storage/src/backend.ts b/packages/storage/storage/src/backend.ts index 9c92a42b04..a483b4dc59 100644 --- a/packages/storage/storage/src/backend.ts +++ b/packages/storage/storage/src/backend.ts @@ -109,6 +109,22 @@ export interface KvUnit { */ deleteRecord(table: string, key: string): Promise + /** + * Delete several records durably as one batch. Idempotent: missing keys are + * no-ops. Resolves once every listed record is absent from the readable + * state and durable; a rejection leaves the batch unapplied, so no listed + * record was removed. A backend whose medium can apply a whole batch in one + * operation (the `single` document, a row store) must do that instead of + * repeating {@link deleteRecord} per key. + * @param table - Declared table name. + * @param keys - Record keys; duplicates are harmless. In the `per-record` + * layout every key becomes a path segment and must match `[a-zA-Z0-9_-]+` + * (an unsafe key rejects before any deletion); in the `single` layout keys + * stay opaque. + * @returns resolution after durability. + */ + deleteRecords(table: string, keys: readonly string[]): Promise + /** * Move one record's stored document out of the unit's readable set, * preserving its bytes for inspection instead of deleting them. Backends diff --git a/packages/storage/storage/tests/contract.ts b/packages/storage/storage/tests/contract.ts index d173918dce..4a2c872ac8 100644 --- a/packages/storage/storage/tests/contract.ts +++ b/packages/storage/storage/tests/contract.ts @@ -71,6 +71,24 @@ export function runKvBackendContract(label: string, create: () => Promise { + const harness = await create() + const unit = await harness.backend.kv!.open(DESCRIPTOR) + await unit.putRecord('alpha', 'k1', { n: 1 }) + await unit.putRecord('alpha', 'k2', { n: 2 }) + await unit.putRecord('alpha', 'k3', { n: 3 }) + await unit.putRecord('beta', 'b1', { n: 4 }) + await unit.deleteRecords('alpha', ['k1', 'k3', 'missing', 'k1']) + await unit.deleteRecords('alpha', []) + expect((await unit.loadAll()).tables).toEqual({ alpha: { k2: { n: 2 } }, beta: { b1: { n: 4 } } }) + await harness.backend.close() + + const reopened = await harness.reopen() + const unit2 = await reopened.kv!.open(DESCRIPTOR) + expect((await unit2.loadAll()).tables).toEqual({ alpha: { k2: { n: 2 } }, beta: { b1: { n: 4 } } }) + await reopened.close() + }) + it('rejects a version mismatch on reopen without touching the data', async () => { const harness = await create() const unit = await harness.backend.kv!.open(DESCRIPTOR) @@ -95,6 +113,7 @@ export function runKvBackendContract(label: string, create: () => Promise unit.deleteRecords(table, keys), setGlobal: async (value) => { globals += 1 const failAt = Array.isArray(failure.globalAt) ? failure.globalAt : [failure.globalAt] From ca8cc623192fdf217e61f9d7930b26db9dd6b6c5 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 21 Oct 2026 12:40:13 +0800 Subject: [PATCH 08/85] fix(web): align subagent switcher rows without a sidebar button Refs #6803 --- .../src/client/SubagentHeaderLineage.module.css | 8 ++++++-- .../ui-subagent/src/client/SubagentHeaderLineage.tsx | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css index 94b87097d1..842c33fd7f 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css @@ -285,12 +285,16 @@ grid-row: 2; } +.sidebarButton, +.sidebarSpace { + flex: none; + width: 28px; +} + .sidebarButton { display: inline-flex; - flex: none; align-items: center; justify-content: center; - width: 28px; height: 28px; margin: 4px 0; padding: 6px; diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx index 6cd38d6d93..48b514bbd3 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx @@ -353,7 +353,7 @@ function CatalogRows({ )} )} - {!isCurrent && !external && ( + {isCurrent || external ? : ( - - )} + {isCurrent || external + ? + : ( + + + + )} {isExpanded && ( diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index 24ecf8fb28..e18096a6b7 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -1098,6 +1098,8 @@ describe('SubagentHeaderLineage', () => { const current = screen.getByRole('treeitem', { name: /worker/ }) expect(current.getAttribute('aria-current')).toBe('true') expect(within(current).getByText('worker').className).toContain('currentLabel') + expect(within(current).queryByRole('button', { name: '在侧边栏打开 worker' })).toBeNull() + expect(within(screen.getByRole('treeitem', { name: /reviewer/ })).getByRole('button', { name: '在侧边栏打开 reviewer' })).toBeTruthy() fireEvent.click(screen.getByRole('treeitem', { name: /reviewer/ })) expect(input.openChild).toHaveBeenCalledWith({ parentSessionId: PARENT, From 500426d4bd801ae121025c84f489ffaf7d5ec55d Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 21 Oct 2026 13:38:20 +0800 Subject: [PATCH 10/85] test(schedule): cover a large ended set and repeated bulk removals --- .../schedule/tests/delete-inactive.perf.ts | 181 ++++++++++++++++++ .../schedule/tests/delete-inactive.spec.ts | 70 ++++++- 2 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 packages/schedule/schedule/tests/delete-inactive.perf.ts diff --git a/packages/schedule/schedule/tests/delete-inactive.perf.ts b/packages/schedule/schedule/tests/delete-inactive.perf.ts new file mode 100644 index 0000000000..3ac989c1f0 --- /dev/null +++ b/packages/schedule/schedule/tests/delete-inactive.perf.ts @@ -0,0 +1,181 @@ +/** + * Threshold-free measurement of one `deleteInactive` call over the default + * whole-unit JSON medium. Seeds the medium by writing the unit file directly + * (seeding through the API would publish the growing file once per row), then + * boots the production plugin and times the removal, the whole-unit publish it + * triggers, and the batch-call count the storage layer sees. + */ + +import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import Storage from '@deepseek-ai/dsh-storage' +import type { StorageBackend } from '@deepseek-ai/dsh-storage' +import { DomainFacility } from '@deepseek-ai/dsh-storage-domain' +import { JsonStorageBackend } from '@deepseek-ai/dsh-storage-json' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import SessionStore from '@deepseek-ai/dsh-session' +import ScheduleService from '../src/index.ts' +import { createAfterScheduleRecord, createEveryScheduleRecord, ScheduleId } from '../src/domain.ts' + +interface Args { + ended: number + active: number + mode: 'batch' | 'per-row' +} + +function parseArgs(argv: readonly string[]): Args { + const value = (name: string, fallback: number): number => { + const entry = argv.find(argument => argument.startsWith(`--${name}=`)) + if (entry === undefined) return fallback + const parsed = Number(entry.slice(name.length + 3)) + if (!Number.isSafeInteger(parsed) || parsed < 0) throw new Error(`--${name} must be a non-negative integer`) + return parsed + } + const mode = argv.find(argument => argument.startsWith('--mode='))?.slice('--mode='.length) ?? 'batch' + if (mode !== 'batch' && mode !== 'per-row') throw new Error('--mode must be batch or per-row') + return { ended: value('ended', 500_000), active: value('active', 500_000), mode } +} + +/** Serialized schedule unit with the requested ended and active rows. */ +function seedText(ended: number, active: number): string { + const now = Date.now() + const tasks: Record = {} + for (let index = 0; index < ended; index += 1) { + const id = `ended-${index}` + tasks[id] = { + sessionId: `ended-session-${index}`, + record: createAfterScheduleRecord(ScheduleId(id), `Ended ${index}`, 60, now - 5 * 60_000, `Ended ${index}`), + status: 'inactive', + } + } + for (let index = 0; index < active; index += 1) { + const id = `active-${index}` + tasks[id] = { + sessionId: `active-session-${index}`, + record: createEveryScheduleRecord(ScheduleId(id), `Active ${index}`, 3_600, now, `Active ${index}`), + status: 'active', + } + } + return JSON.stringify({ unit: { name: 'schedule', version: 1 }, global: null, tables: { tasks } }) +} + +/** Backend façade counting how many deletions reach the medium. */ +function counted(inner: StorageBackend): { + backend: StorageBackend + batches: () => number + singles: () => number +} { + let batches = 0 + let singles = 0 + const kv = inner.kv + if (kv === undefined) throw new Error('json backend is missing its kv facet') + return { + batches: () => batches, + singles: () => singles, + backend: { + close: () => inner.close(), + kv: { + open: async (descriptor) => { + const unit = await kv.open(descriptor) + const backup = unit.backupRecord?.bind(unit) + return { + loadAll: () => unit.loadAll(), + putRecord: (table, key, value) => unit.putRecord(table, key, value), + deleteRecord: async (table, key) => { + singles += 1 + await unit.deleteRecord(table, key) + }, + deleteRecords: async (table, keys) => { + batches += 1 + await unit.deleteRecords(table, keys) + }, + setGlobal: value => unit.setGlobal(value), + close: () => unit.close(), + ...(backup === undefined ? {} : { backupRecord: backup }), + } + }, + }, + }, + } +} + +async function measure(args: Args) { + const root = await mkdtemp(join(tmpdir(), 'dsh-schedule-clear-perf-')) + try { + const path = join(root, 'schedule.json') + const seedStart = performance.now() + await writeFile(path, seedText(args.ended, args.active)) + const seedMs = performance.now() - seedStart + const seedBytes = (await stat(path)).size + + const json = new JsonStorageBackend(root) + const { backend, batches, singles } = counted(json) + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(SystemPrompt, {}) + await ctx.plugin(ToolRuntime) + await ctx.plugin(Storage) + ctx.effect(() => ctx.storage.backend.register('fixture', backend)) + const facility = new DomainFacility(ctx, { backend: 'fixture' }) + ctx.effect(() => { + const unmount = ctx.storage.mount('domain', facility) + ctx.provide('storageDomain', facility) + return async () => { + await facility.closeAll() + unmount() + } + }) + ctx.provide('sessionController', { + resolveAgent: async () => { throw new Error('perf measurement does not activate Sessions') }, + } as never) + ctx.provide('sessionPersistence', {} as never) + ctx.on('session/flush', async () => {}) + + const openStart = performance.now() + await ctx.plugin(ScheduleService, {}) + const openMs = performance.now() - openStart + const start = performance.now() + const result = args.mode === 'batch' + ? await ctx.schedule.deleteInactive() + : await (async () => { + const table = facility.get('schedule')?.table('tasks') + if (table === undefined) throw new Error('schedule domain is not open') + for (let index = 0; index < args.ended; index += 1) { + await table.delete(ScheduleId(`ended-${index}`)) + } + return { deleted: args.ended } + })() + const deleteMs = performance.now() - start + const afterBytes = (await stat(path)).size + const rss = process.memoryUsage().rss / (1024 * 1024) + await ctx.fiber.dispose() + return { + node: process.version, + platform: `${process.platform}-${process.arch}`, + ended: args.ended, + active: args.active, + mode: args.mode, + seedMs: Math.round(seedMs), + seedBytes, + openMs: Math.round(openMs), + deleteMs: Math.round(deleteMs), + afterBytes, + deleted: result.deleted, + deleteRecordsCalls: batches(), + deleteRecordCalls: singles(), + rssMB: Math.round(rss), + } + } finally { + await rm(root, { recursive: true, force: true }) + } +} + +process.stdout.write(`${JSON.stringify(await measure(parseArgs(process.argv.slice(2))), null, 2)}\n`) diff --git a/packages/schedule/schedule/tests/delete-inactive.spec.ts b/packages/schedule/schedule/tests/delete-inactive.spec.ts index d1222d010d..707a20a9b7 100644 --- a/packages/schedule/schedule/tests/delete-inactive.spec.ts +++ b/packages/schedule/schedule/tests/delete-inactive.spec.ts @@ -2,7 +2,8 @@ import { SessionId } from '@deepseek-ai/dsh-session' import { MessageId } from '@deepseek-ai/dsh-llm/brand' import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { createAfterScheduleRecord, ScheduleId } from '../src/domain.ts' +import { createAfterScheduleRecord, createEveryScheduleRecord, ScheduleId } from '../src/domain.ts' +import type { DomainFacility } from '@deepseek-ai/dsh-storage-domain' import { scheduleDomain, type ScheduleTask } from '../src/storage.ts' import { MemoryMediaPool } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' import { gatedScheduleBackend, harness } from './harness.ts' @@ -98,6 +99,73 @@ it('reports zero and announces nothing when the Host holds no ended task', async expect(await service.catalog()).toHaveLength(1) }) +it('clears a large ended set in one batch and stays idempotent across repeated cleanups', async () => { + const pool = new MemoryMediaPool() + const batches: string[][] = [] + const backend = gatedScheduleBackend(pool, { + deleteRecords: async (table, keys) => { + if (table === 'tasks') batches.push([...keys]) + }, + }) + // Half the rows are ended one-shots and half stay armed, so the scan has to + // separate them and the batch has to leave the active rows untouched. + const endedCount = 5_000 + const activeCount = 5_000 + let facilityRef: DomainFacility | undefined + const test = await harness({ + pool, + backend, + beforeService: async (_ctx, facility) => { + facilityRef = facility + const domain = await facility.open(scheduleDomain) + const tasks = domain.table('tasks') + const now = Date.now() + for (let index = 0; index < endedCount; index += 1) { + const id = `ended-${index}` + await tasks.put(ScheduleId(id), endedRow(SessionId(`ended-session-${index}`), id, `Ended ${index}`)) + } + for (let index = 0; index < activeCount; index += 1) { + const id = `active-${index}` + await tasks.put(ScheduleId(id), { + sessionId: SessionId(`active-session-${index}`), + record: createEveryScheduleRecord(ScheduleId(id), `Active ${index}`, 3_600, now, `Active ${index}`), + status: 'active', + }) + } + await domain.close() + }, + }) + tests.push(test) + const { ctx, service } = test + const changed = vi.fn() + ctx.on('schedule/changed', changed) + + expect(await service.deleteInactive()).toEqual({ deleted: endedCount }) + expect(batches).toHaveLength(1) + expect(batches[0]).toHaveLength(endedCount) + expect(changed).toHaveBeenCalledTimes(1) + expect(await service.catalog()).toHaveLength(activeCount) + expect((await service.catalog()).every(entry => entry.status === 'active')).toBe(true) + + // Repeated cleanups reseed a small ended set through the live domain table, + // so each round exercises the same single-batch path without a growing file. + const reseeded = 100 + const rounds = 24 + const table = facilityRef?.get('schedule')?.table('tasks') + expect(table).toBeDefined() + for (let round = 0; round < rounds; round += 1) { + for (let index = 0; index < reseeded; index += 1) { + const id = `again-${round}-${index}` + await table!.put(ScheduleId(id), endedRow(SessionId(`again-session-${round}-${index}`), id, `Again ${round}-${index}`)) + } + expect(await service.deleteInactive()).toEqual({ deleted: reseeded }) + expect(await service.catalog()).toHaveLength(activeCount) + } + expect(batches).toHaveLength(rounds + 1) + expect(batches.every((keys, index) => keys.length === (index === 0 ? endedCount : reseeded))).toBe(true) + expect(changed).toHaveBeenCalledTimes(rounds + 1) +}) + it('removes nothing and reports the batch failure', async () => { const pool = new MemoryMediaPool() const batches: string[][] = [] From c8d537db438bb453597bf7f7e42554c9583ae743 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 21 Oct 2026 17:12:57 +0800 Subject: [PATCH 11/85] fix(client): complete the Japanese dictionaries after merging master --- .../client/ui-conversation/src/client/locales.ts | 1 + .../ui-schedule/src/client/task-manager-locales.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index f908a94233..36edb38e56 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -772,6 +772,7 @@ export const en = { /** Japanese copy, checked against the English keys. */ export const ja = { + 'quote.remove': '引用を削除', 'shortcut.newline': '改行', 'shortcut.complementary': '補助のキュー / 実行中の指示変更を使用', 'shortcut.slash': 'コマンドメニューを開く', 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 642ddda69a..3cf581f238 100644 --- a/packages/client/ui-schedule/src/client/task-manager-locales.ts +++ b/packages/client/ui-schedule/src/client/task-manager-locales.ts @@ -440,8 +440,20 @@ export const ja = { 'delete.cancel': 'キャンセル', 'delete.close': '削除確認を閉じる', 'delete.pending': '削除中…', + 'clear.action': '終了済みをクリア', + 'clear.title': '終了した自動化タスクをクリアしますか?', + 'clear.description': '各タスクは保存済みの実行履歴とともに削除されます。元のセッションとそのメッセージは残ります。', + 'clear.count.one': '終了したタスク {count} 件', + 'clear.count.other': '終了したタスク {count} 件', + 'clear.confirm': 'クリアする', + 'clear.pending': 'クリア中…', + 'clear.close': 'クリア確認を閉じる', 'toast.deleted': 'タスクを削除しました。', 'toast.deleteFailed': 'タスクを削除できませんでした。', + 'toast.cleared.one': '終了したタスクを {count} 件クリアしました。', + 'toast.cleared.other': '終了したタスクを {count} 件クリアしました。', + 'toast.clearedNone': 'クリアする終了済みタスクはありません。', + 'toast.clearFailed': '終了したタスクをすべてクリアできませんでした。', 'card.open': '開く', 'card.openLabel': 'タスクの詳細を開く: {title}', 'card.deleted': '削除済み', From ac465000d78444dd3e8311889f9ae6d3b034bde6 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 21 Oct 2026 17:33:43 +0800 Subject: [PATCH 12/85] fix(client): complete the merged locale and snapshot fixtures --- packages/session/message-quotes/src/client/index.ts | 4 ++-- packages/session/message-quotes/src/client/locales.ts | 5 +++++ .../sdk/background-wait-steer/tool-schemas.expected.json | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/session/message-quotes/src/client/index.ts b/packages/session/message-quotes/src/client/index.ts index 09e352267e..b0748db925 100644 --- a/packages/session/message-quotes/src/client/index.ts +++ b/packages/session/message-quotes/src/client/index.ts @@ -8,7 +8,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import { QuoteSelectionAction } from './QuoteSelection.tsx' import { QuotedUserText } from './QuotedUserText.tsx' import { quoteRule } from './quote-rule.ts' -import { en, NS, zh, type MessageQuotesKey } from './locales.ts' +import { en, ja, NS, zh, type MessageQuotesKey } from './locales.ts' export type {} from './quote-rule.ts' @@ -30,7 +30,7 @@ export const inject = ['slots', 'locale', 'sessions', 'uiConversation'] * @param ctx - Client root context owning every Quote contribution. */ export function apply(ctx: Context): void { - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'message-quotes: dictionaries') + ctx.effect(() => ctx.locale.register(NS, { zh, en, ja }), 'message-quotes: dictionaries') ctx.effect(() => ctx.uiConversation.userText.register(quoteRule), 'message-quotes: UserText rule') ctx.effect(() => ctx.uiConversation.interactions.register({ id: 'quote', diff --git a/packages/session/message-quotes/src/client/locales.ts b/packages/session/message-quotes/src/client/locales.ts index c36e0d3599..a712197cd5 100644 --- a/packages/session/message-quotes/src/client/locales.ts +++ b/packages/session/message-quotes/src/client/locales.ts @@ -15,3 +15,8 @@ export type MessageQuotesKey = keyof typeof zh export const en: Record = { 'quote.add': 'Quote text', } + +/** Japanese dictionary. */ +export const ja: Record = { + 'quote.add': 'テキストを引用', +} diff --git a/snapshots/sdk/background-wait-steer/tool-schemas.expected.json b/snapshots/sdk/background-wait-steer/tool-schemas.expected.json index 90228febab..c9fb03068a 100644 --- a/snapshots/sdk/background-wait-steer/tool-schemas.expected.json +++ b/snapshots/sdk/background-wait-steer/tool-schemas.expected.json @@ -325,7 +325,7 @@ }, { "name": "present", - "description": "Declare existing files as final deliverables for the user. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied.", + "description": "Declare existing files as final deliverables for the user. Hosts may show presented files only after your turn ends, so call it once the files are final. If the user needs to review files earlier, such as before a tool call that waits for their answer, link them in your message instead. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied.", "parameters": { "type": "object", "properties": { From 11e1580b829e03a281b0b828570de82ae836299c Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 21 Oct 2026 22:45:28 +0800 Subject: [PATCH 13/85] fix(ui-conversation): keep the active tab bar visible over Trajectory and widen tab targets Co-authored-by: Shigma <33423008+shigma@users.noreply.github.com> --- apps/web/tests/window-drag-coverage.e2e.ts | 4 +++- .../skeleton/ConversationRoot.module.css | 19 +++++++++++++------ .../tests/header-styles.client.spec.ts | 6 ++++-- .../ui-trajectory/src/client/views.module.css | 6 +++++- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/apps/web/tests/window-drag-coverage.e2e.ts b/apps/web/tests/window-drag-coverage.e2e.ts index cb6df06778..142fcb7431 100644 --- a/apps/web/tests/window-drag-coverage.e2e.ts +++ b/apps/web/tests/window-drag-coverage.e2e.ts @@ -276,7 +276,9 @@ describe('web e2e: macOS window drag coverage', () => { { row: 'conversation header', what: 'the blank run right of the view tabs, on the tab strip row', - at: () => [tabs.x + tabs.width + 8, tabs.y + tabs.height / 2], + // The last tab's press target overhangs the tablist by 10px and the + // More launcher starts 20px past it; probe the run between them. + at: () => [tabs.x + tabs.width + 15, tabs.y + tabs.height / 2], drag: true, }, { diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index cf92656162..ae797ac695 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -212,12 +212,19 @@ min-height: 25px; } -/* The 16px text line retains 9px below it. The active bar reaches 1px past - the tab box to sit over the header rule. */ +/* The 16px text line retains 9px below it. The press target extends 10px + above the text into the strip's top margin and 10px to each side, as the + Sidebar tab chips do (ui-dockkit `.tab`); negative margins keep the layout + at the text box. The active bar spans the text and reaches 1px past the tab + box to sit over the header rule. */ .tab { + --dsh-tab-hit-inline: 10px; + position: relative; - min-height: 25px; - padding: 0 0 9px; + box-sizing: border-box; + min-height: 35px; + margin: -10px calc(-1 * var(--dsh-tab-hit-inline)) 0; + padding: 10px var(--dsh-tab-hit-inline) 9px; border: none; background: transparent; font-size: 13px; @@ -230,9 +237,9 @@ .tab::after { content: ''; position: absolute; - right: 0; + right: var(--dsh-tab-hit-inline); bottom: -1px; - left: 0; + left: var(--dsh-tab-hit-inline); height: 2px; border-radius: 2px; background: transparent; diff --git a/packages/client/ui-conversation/tests/header-styles.client.spec.ts b/packages/client/ui-conversation/tests/header-styles.client.spec.ts index c3abd2bcf4..3c964950d0 100644 --- a/packages/client/ui-conversation/tests/header-styles.client.spec.ts +++ b/packages/client/ui-conversation/tests/header-styles.client.spec.ts @@ -8,8 +8,10 @@ describe('conversation header geometry', () => { it('keeps the single-line header at the shared 76px height', () => { expect(css.match(/\.header\s*\{([^}]*)\}/)?.[1]).toContain('min-height: 76px') expect(css.match(/\.viewTabs\s*\{([^}]*)\}/)?.[1]).toContain('min-height: 25px') - expect(css.match(/\.tab\s*\{([^}]*)\}/)?.[1]).toContain('min-height: 25px') - expect(css.match(/\.tab\s*\{([^}]*)\}/)?.[1]).toContain('padding: 0 0 9px') + // The tab's 35px press target sits 10px above its 25px row through a negative top margin. + expect(css.match(/\.tab\s*\{([^}]*)\}/)?.[1]).toContain('min-height: 35px') + expect(css.match(/\.tab\s*\{([^}]*)\}/)?.[1]).toContain('margin: -10px calc(-1 * var(--dsh-tab-hit-inline)) 0') + expect(css.match(/\.tab\s*\{([^}]*)\}/)?.[1]).toContain('padding: 10px var(--dsh-tab-hit-inline) 9px') }) it('uses 32px between view labels without changing the action gap', () => { diff --git a/packages/client/ui-trajectory/src/client/views.module.css b/packages/client/ui-trajectory/src/client/views.module.css index 687f4a4657..7915da5cc1 100644 --- a/packages/client/ui-trajectory/src/client/views.module.css +++ b/packages/client/ui-trajectory/src/client/views.module.css @@ -1,7 +1,11 @@ -/* Full-bleed, fixed-height host for the trajectory ledger. */ +/* Full-bleed, fixed-height host for the trajectory ledger. Isolation keeps the + sticky toolbar's z-index inside this view, so the conversation tab row + (ui-conversation `.tabs`, z-index 1) still paints its active bar over the + view's top edge. */ .root { --dsh-trajectory-toolbar-height: 32px; + isolation: isolate; display: flex; flex-direction: column; overflow: hidden; From d229d1cf758869ce06e24b6babe95a73285482c0 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 21 Oct 2026 22:48:12 +0800 Subject: [PATCH 14/85] docs(ui-conversation): state the tab press target comments exactly Co-authored-by: Shigma <33423008+shigma@users.noreply.github.com> --- .../src/client/skeleton/ConversationRoot.module.css | 9 +++++---- .../client/ui-trajectory/src/client/views.module.css | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index ae797ac695..0ce8d1b4b6 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -213,10 +213,11 @@ } /* The 16px text line retains 9px below it. The press target extends 10px - above the text into the strip's top margin and 10px to each side, as the - Sidebar tab chips do (ui-dockkit `.tab`); negative margins keep the layout - at the text box. The active bar spans the text and reaches 1px past the tab - box to sit over the header rule. */ + above the text into the strip's top margin and 10px to each side, matching + the inline padding of the Sidebar tab chips (ui-dockkit `.tab`); negative + margins keep the layout box at its 25px row and text width. The active bar + spans the text and reaches 1px past the tab box to sit over the header + rule. */ .tab { --dsh-tab-hit-inline: 10px; diff --git a/packages/client/ui-trajectory/src/client/views.module.css b/packages/client/ui-trajectory/src/client/views.module.css index 7915da5cc1..c7c963a5b2 100644 --- a/packages/client/ui-trajectory/src/client/views.module.css +++ b/packages/client/ui-trajectory/src/client/views.module.css @@ -1,7 +1,7 @@ /* Full-bleed, fixed-height host for the trajectory ledger. Isolation keeps the sticky toolbar's z-index inside this view, so the conversation tab row - (ui-conversation `.tabs`, z-index 1) still paints its active bar over the - view's top edge. */ + stacked above the view still paints its active bar over the view's top + edge. */ .root { --dsh-trajectory-toolbar-height: 32px; From d343cd216236d74c40f5113ef2328f1ee5f022b2 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 21 Oct 2026 22:51:47 +0800 Subject: [PATCH 15/85] fix(ui-primitives): keep header menus next to their trigger on macOS desktop The desktop top clearance pushed every portalled overlay at least 68px from the viewport top, so menus opened from Conversation header controls inside that strip landed about 30px below their trigger. Cap the clearance at the position directly below a visible anchor. Refs #6886 --- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../client/ui-primitives/src/HoverCard.tsx | 4 +- packages/client/ui-primitives/src/Menu.tsx | 2 +- .../ui-primitives/src/overlay-top-margin.ts | 11 ++-- .../ui-primitives/src/useAnchoredPosition.ts | 2 +- .../tests/hover-card.client.spec.tsx | 4 ++ .../tests/menu-top-clearance.client.spec.tsx | 50 +++++++++++++++++++ .../use-anchored-position.client.spec.tsx | 16 ++++-- 10 files changed, 81 insertions(+), 16 deletions(-) create mode 100644 packages/client/ui-primitives/tests/menu-top-clearance.client.spec.tsx diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 9a13d1d0a6..6fcc3211c3 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -21,8 +21,8 @@ en: cb874a0ca53a6747 zh: 31179193dd1d6e12 /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: - en: fff68b60f4abdc7e - zh: 5cf5952ec78f7f91 + en: cac48e14b2746247 + zh: b768225ef33a44cb /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: en: 629f57d60fe5cce1 zh: 5263447a14c21d61 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index ecd3fd4e29..1756a70c39 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -88,7 +88,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists follow their anchor during dragging and CSS transforms, and stop tracking when closed. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers; an overlay opened below an anchor inside that top strip stays next to its anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists follow their anchor during dragging and CSS transforms, and stop tracking when closed. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. `Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. It stays hidden until its first fit, prefers the requested side, then tries the opposite and perpendicular sides, sliding along the anchor without covering it when a side fits. Horizontal placements keep a 10px gap; vertical placements use `gap` (8px by default). Width and height stay within a 12px viewport margin; text wraps, and content taller than the viewport scrolls when pinned. If no side fits, a stable viewport-bounded fallback can overlap the anchor. Viewport resize refreshes the anchor coordinates; label-size changes reuse them. Fitting does not synchronously measure the bubble or trigger a React render. Touch presses dismiss unpinned hints and do not trigger hover hints, including compatibility mouse events. Mouse entry opens hints only while `(any-hover: hover)` reports an available hover-capable input, excluding iOS mouse entry caused by a tapped icon changing. Keyboard focus and explicit click-to-open explanations remain available on touch-only devices. Opt-in `openOnClick` lets an informational button pin the bubble by click or tap and associates it with the anchor description. Another click or tap, Escape, Tab, or an outside pointerdown closes it. Ordinary action buttons execute their action directly without a touch tooltip. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 3de121ab2c..d5bd8a1e58 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -88,7 +88,7 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距;在该顶部区域内的锚点下方打开的浮层紧贴其锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 `Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前保持隐藏,优先使用指定方向,再尝试相反方向和垂直于该方向的两侧;只要某一侧容得下,就沿锚点平移而不遮挡锚点。左右放置保留 10px 间距,上下放置使用 `gap`(默认 8px)。宽高限制在视口内并保留 12px 边距;文本自动换行,超出视口高度的内容在固定气泡后可滚动。若所有方向都放不下,则使用稳定的视口内回退位置,可能遮挡锚点。视口尺寸变化会重新读取锚点坐标,标签尺寸变化则复用坐标;定位不会同步测量气泡,也不会触发 React 渲染。 触摸按下会关闭未固定的提示,且不会触发悬停提示,包括触摸后的兼容鼠标事件。只有 `(any-hover: hover)` 表明存在可悬停的输入设备时,鼠标进入才会打开提示,从而排除 iOS 因已点击图标变化而生成的鼠标进入事件。仅有触摸输入的设备仍可使用键盘聚焦提示和显式点击打开的说明。信息按钮可显式启用 `openOnClick`,通过鼠标或触摸点击固定气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮在触摸点击时直接执行操作,不显示 Tooltip。 diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 157cf35f06..e009ac98cb 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -178,7 +178,7 @@ export function HoverCard({ if (variant === 'preview') { const bounds = widthAnchorRef?.current?.getBoundingClientRect() ?? r const width = Math.max(0, Math.min(bounds.width - PREVIEW_INSET * 2, window.innerWidth - VIEWPORT_MARGIN * 2)) - const topMargin = overlayTopMargin(VIEWPORT_MARGIN) + const topMargin = overlayTopMargin(VIEWPORT_MARGIN, r, ANCHOR_GAP) const belowTop = Math.max(topMargin, r.bottom + ANCHOR_GAP) const above = Math.max(0, r.top - ANCHOR_GAP - topMargin) const below = Math.max(0, window.innerHeight - belowTop - VIEWPORT_MARGIN) @@ -195,7 +195,7 @@ export function HoverCard({ const height = Math.max(h, cardRef.current?.scrollHeight ?? 0) const maxWidth = Math.max(0, Math.min(INLINE_PREVIEW_WIDTH, window.innerWidth - VIEWPORT_MARGIN * 2)) const width = fitContent ? Math.min(cardRef.current?.offsetWidth ?? maxWidth, maxWidth) : maxWidth - const topMargin = overlayTopMargin(VIEWPORT_MARGIN) + const topMargin = overlayTopMargin(VIEWPORT_MARGIN, r, ANCHOR_GAP) const belowTop = Math.max(topMargin, r.bottom + ANCHOR_GAP) const above = Math.max(0, r.top - ANCHOR_GAP - topMargin) const below = Math.max(0, window.innerHeight - belowTop - VIEWPORT_MARGIN) diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index 0f1225fc42..ded3d67afd 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -279,7 +279,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI } if (lw > 0) x = Math.min(Math.max(x, MARGIN), vw - lw - MARGIN) - if (lh > 0) y = Math.min(Math.max(y, overlayTopMargin(MARGIN)), vh - lh - MARGIN) + if (lh > 0) y = Math.min(Math.max(y, overlayTopMargin(MARGIN, r, 4)), vh - lh - MARGIN) setFixedPos(current => current?.left === x && current.top === y ? current : { left: x, top: y }) } diff --git a/packages/client/ui-primitives/src/overlay-top-margin.ts b/packages/client/ui-primitives/src/overlay-top-margin.ts index 02ed679280..bb12ff729c 100644 --- a/packages/client/ui-primitives/src/overlay-top-margin.ts +++ b/packages/client/ui-primitives/src/overlay-top-margin.ts @@ -3,11 +3,16 @@ /** * Resolve the top margin an overlay keeps from the viewport edge. * @param min - the overlay's own viewport margin in px, used as the floor. - * @returns the larger of `min` and the frame clearance plus 20px; fullscreen keeps only the 20px gap. + * @param anchor - the overlay's anchor rect in viewport coordinates. While the anchor's top edge is inside the + * viewport, the margin never exceeds `anchor.bottom + gap`, so an overlay opened from a control inside the top strip + * stays next to it. + * @param gap - the distance the overlay keeps below `anchor`. + * @returns the frame clearance plus 20px (20px in fullscreen), capped by a visible `anchor`, but never below `min`. */ -export function overlayTopMargin(min: number): number { +export function overlayTopMargin(min: number, anchor?: { readonly top: number; readonly bottom: number }, gap = 0): number { const root = document.documentElement const clearance = Number.parseFloat(getComputedStyle(root).getPropertyValue('--dsh-frame-top-clearance')) if (Number.isNaN(clearance)) return min - return Math.max(min, (root.hasAttribute('data-fullscreen') ? 0 : clearance) + 20) + const cap = anchor !== undefined && anchor.top >= 0 ? anchor.bottom + gap : Number.POSITIVE_INFINITY + return Math.max(min, Math.min((root.hasAttribute('data-fullscreen') ? 0 : clearance) + 20, cap)) } diff --git a/packages/client/ui-primitives/src/useAnchoredPosition.ts b/packages/client/ui-primitives/src/useAnchoredPosition.ts index f87e4d0697..11256ae00a 100644 --- a/packages/client/ui-primitives/src/useAnchoredPosition.ts +++ b/packages/client/ui-primitives/src/useAnchoredPosition.ts @@ -56,7 +56,7 @@ export function useAnchoredPosition(options: AnchoredPositionOptions): CSSProper let left = align === 'end' ? rect.right - width : rect.left let top = side === 'top' ? rect.top - gap - height : rect.bottom + gap if (width > 0) left = Math.min(Math.max(left, margin), window.innerWidth - width - margin) - if (height > 0) top = Math.min(Math.max(top, overlayTopMargin(margin)), window.innerHeight - height - margin) + if (height > 0) top = Math.min(Math.max(top, overlayTopMargin(margin, rect, gap)), window.innerHeight - height - margin) /* v8 ignore stop */ setPosition({ left, top }) } diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index 0f8a67c08b..2e0ba4e709 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -258,6 +258,10 @@ describe('HoverCard', () => { fireEvent(window, new Event('resize')) expect(card.style.top).toBe('120px') expect(card.style.maxHeight).toBe('322px') + // An anchor inside the top strip keeps its preview directly below it. + wrapper.getBoundingClientRect = () => DOMRect.fromRect({ x: 100, y: 10, width: 600, height: 32 }) + fireEvent(window, new Event('resize')) + expect(card.style.top).toBe('50px') } finally { if (prior === '') document.documentElement.style.removeProperty('--dsh-frame-top-clearance') else document.documentElement.style.setProperty('--dsh-frame-top-clearance', prior, priority) diff --git a/packages/client/ui-primitives/tests/menu-top-clearance.client.spec.tsx b/packages/client/ui-primitives/tests/menu-top-clearance.client.spec.tsx new file mode 100644 index 0000000000..08bc55e623 --- /dev/null +++ b/packages/client/ui-primitives/tests/menu-top-clearance.client.spec.tsx @@ -0,0 +1,50 @@ +// @vitest-environment jsdom +/** Portal menus keep the desktop frame's top clearance without leaving a gap below an anchor inside it. */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render } from '@testing-library/react' +import { Menu } from '../src/Menu.tsx' + +const root = document.documentElement +let prior = '' + +beforeEach(() => { + prior = root.style.getPropertyValue('--dsh-frame-top-clearance') + root.style.setProperty('--dsh-frame-top-clearance', '48px') +}) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() + if (prior === '') root.style.removeProperty('--dsh-frame-top-clearance') + else root.style.setProperty('--dsh-frame-top-clearance', prior) +}) + +/** + * Open an 80px-tall portal menu from an anchor with the stated rect. + * @param anchor - the anchor rect in viewport coordinates. + * @param side - which anchor edge the menu opens from. + * @returns the render result. + */ +function openMenu(anchor: DOMRect, side: 'top' | 'bottom') { + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(anchor) + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(120) + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(80) + return render(Open} items={[{ id: 'one', label: 'One' }]} onClose={vi.fn()} />) +} + +describe('Menu top clearance', () => { + it('opens directly below an anchor that sits inside the top strip', () => { + const view = openMenu(new DOMRect(300, 14, 18, 22), 'bottom') + expect(view.getByRole('menu').style.top).toBe('40px') + }) + + it('keeps a menu that does not fit above its anchor below the top strip', () => { + const view = openMenu(new DOMRect(300, 100, 18, 22), 'top') + expect(view.getByRole('menu').style.top).toBe('68px') + }) + + it('keeps the top strip clear when the anchor has scrolled above the viewport', () => { + const view = openMenu(new DOMRect(300, -30, 18, 22), 'bottom') + expect(view.getByRole('menu').style.top).toBe('68px') + }) +}) diff --git a/packages/client/ui-primitives/tests/use-anchored-position.client.spec.tsx b/packages/client/ui-primitives/tests/use-anchored-position.client.spec.tsx index a3083af168..5dfb8d1e7e 100644 --- a/packages/client/ui-primitives/tests/use-anchored-position.client.spec.tsx +++ b/packages/client/ui-primitives/tests/use-anchored-position.client.spec.tsx @@ -51,10 +51,12 @@ function stubResizeObserver(): Recorded[] { * @param props - whether the panel is open. * @returns the anchor and, while open, the panel carrying the position. */ -function Host({ open, align }: { open: boolean; align?: 'start' | 'end' }) { +function Host({ open, align, side }: { open: boolean; align?: 'start' | 'end'; side?: 'top' | 'bottom' }) { const anchorRef = useRef(null) const panelRef = useRef(null) - const position = useAnchoredPosition({ open, anchorRef, panelRef, gap: 4, margin: 12, ...align === undefined ? {} : { align } }) + const position = useAnchoredPosition({ + open, anchorRef, panelRef, gap: 4, margin: 12, ...align === undefined ? {} : { align }, ...side === undefined ? {} : { side }, + }) return ( <> @@ -64,7 +66,11 @@ function Host({ open, align }: { open: boolean; align?: 'start' | 'end' }) { } describe('useAnchoredPosition', () => { - it.each([{ fullscreen: false, top: 60 }, { fullscreen: true, top: 20 }])('keeps an anchored panel at $top with fullscreen=$fullscreen', ({ fullscreen, top }) => { + it.each([ + { name: 'a panel that does not fit above its anchor', side: 'top', anchor: new DOMRect(100, 100, 200, 10), fullscreen: false, top: 60 }, + { name: 'a panel that does not fit above its anchor', side: 'top', anchor: new DOMRect(100, 100, 200, 10), fullscreen: true, top: 20 }, + { name: 'a panel below an anchor inside the top strip', side: 'bottom', anchor: new DOMRect(100, 0, 200, 10), fullscreen: false, top: 14 }, + ] as const)('keeps $name at $top with fullscreen=$fullscreen', ({ side, anchor, fullscreen, top }) => { const root = document.documentElement const previous = root.getAttribute('style') const priorFullscreen = root.getAttribute('data-fullscreen') @@ -77,10 +83,10 @@ describe('useAnchoredPosition', () => { }) root.style.setProperty('--dsh-frame-top-clearance', '40px') root.toggleAttribute('data-fullscreen', fullscreen) - vi.spyOn(HTMLButtonElement.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(100, 0, 200, 10)) + vi.spyOn(HTMLButtonElement.prototype, 'getBoundingClientRect').mockReturnValue(anchor) vi.spyOn(HTMLDivElement.prototype, 'offsetHeight', 'get').mockReturnValue(400) vi.stubGlobal('innerHeight', 600) - const { getByTestId } = render() + const { getByTestId } = render() expect(getByTestId('panel').style.top).toBe(`${top}px`) }) From 4f7ebe8d3423e0e404acc816a901e62b48602622 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 21 Oct 2026 23:15:02 +0800 Subject: [PATCH 16/85] test(web): cover conversation view tab hit testing in the browser Co-authored-by: Shigma <33423008+shigma@users.noreply.github.com> --- apps/web/tests/conversation-view-tabs.e2e.ts | 110 +++++++++++++++++++ apps/web/tsconfig.json | 1 + tsconfig.host.json | 1 + 3 files changed, 112 insertions(+) create mode 100644 apps/web/tests/conversation-view-tabs.e2e.ts diff --git a/apps/web/tests/conversation-view-tabs.e2e.ts b/apps/web/tests/conversation-view-tabs.e2e.ts new file mode 100644 index 0000000000..3995e03b45 --- /dev/null +++ b/apps/web/tests/conversation-view-tabs.e2e.ts @@ -0,0 +1,110 @@ +// Browser hit testing for the conversation view tabs: each tab accepts presses +// beyond its label, and the selected tab's bar stays above the Trajectory +// view's sticky toolbar where the two meet. +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createChatScrollFixture } from './chat-scroll-fixture.ts' +import { launchWebScaffold, seedSession, watchConsole, type WebScaffold } from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const FIXTURE = createChatScrollFixture({ + markerPrefix: 'VIEW_TABS', + title: 'VIEW_TABS short session', + turns: 4, +}) +const SEED_ID = 'conversation-view-tabs-web-e2e' + +/** Label and button rectangles of one tab, and which element owns each probe point. */ +interface TabProbe { + readonly label: { left: number; top: number; right: number; bottom: number } + readonly button: { left: number; top: number; right: number; bottom: number } + readonly ownsAboveLabel: boolean + readonly ownsBesideLabel: boolean + readonly ownsBarOverhang: boolean +} + +/** + * Probe one tab: the run above and beside its label, and the bar's lowest + * half pixel, below the button box. + * @param page - the page under test. + * @param name - the tab's accessible name. + * @returns the tab's rectangles and probe owners. + */ +async function probeTab(page: Page, name: string): Promise { + return page.getByRole('tab', { name, exact: true }).evaluate((tab) => { + const range = document.createRange() + range.selectNodeContents(tab) + const label = range.getBoundingClientRect() + const button = tab.getBoundingClientRect() + const owns = (x: number, y: number): boolean => document.elementFromPoint(x, y)?.closest('[role="tab"]') === tab + const centerX = (label.left + label.right) / 2 + return { + label: { left: label.left, top: label.top, right: label.right, bottom: label.bottom }, + button: { left: button.left, top: button.top, right: button.right, bottom: button.bottom }, + ownsAboveLabel: owns(centerX, label.top - 8), + ownsBesideLabel: owns(label.left - 8, (label.top + label.bottom) / 2), + ownsBarOverhang: owns(centerX, button.bottom + 0.5), + } + }) +} + +async function openSeededSession(page: Page): Promise { + const searchButton = page.getByRole('button', { name: 'Search sessions' }) + if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click() + await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(FIXTURE.markers.user(1)) + const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') + const deadline = Date.now() + 60_000 + while (await results.count() !== 1) { + if (Date.now() > deadline) throw new Error('seeded session never appeared in the sidebar search results') + await page.waitForTimeout(200) + } + await results.click() +} + +describe('web e2e: conversation view tabs', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, FIXTURE.log, SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser, 900) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await openSeededSession(page) + await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false }).last() + .waitFor({ timeout: 30_000 }) + }, 180_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('takes presses above and beside the label', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-view-tabs-target')) + const chat = await probeTab(page, 'Chat') + expect(chat.button.top).toBeLessThan(chat.label.top - 8) + expect(chat.button.left).toBeLessThan(chat.label.left - 8) + expect(chat.ownsAboveLabel).toBe(true) + expect(chat.ownsBesideLabel).toBe(true) + expect(tripwire.pageErrors).toEqual([]) + }) + + it('keeps the selected bar above the Trajectory toolbar', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-view-tabs-trajectory')) + await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() + const toolbar = page.getByRole('toolbar', { name: 'Trajectory toolbar' }) + await toolbar.waitFor({ timeout: 30_000 }) + const trajectory = await probeTab(page, 'Trajectory') + // The toolbar must start within the bar's overhang, or the probe cannot fail. + expect((await toolbar.boundingBox())!.y).toBeLessThanOrEqual(trajectory.button.bottom + 1) + expect(trajectory.ownsBarOverhang).toBe(true) + expect(tripwire.pageErrors).toEqual([]) + }) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 1647c9c331..1e4c7e8dca 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -219,6 +219,7 @@ "tests/chat-long-interactions.e2e.ts", "tests/chat-continuous-conversation.e2e.ts", "tests/composer-tab-geometry.e2e.ts", + "tests/conversation-view-tabs.e2e.ts", "tests/window-drag-coverage.e2e.ts", "tests/complex-history.perf.ts", "tests/pwsh-terminal.e2e.ts", diff --git a/tsconfig.host.json b/tsconfig.host.json index d3feb855de..5b38fda293 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -206,6 +206,7 @@ "apps/web/tests/chat-long-interactions.e2e.ts", "apps/web/tests/chat-continuous-conversation.e2e.ts", "apps/web/tests/composer-tab-geometry.e2e.ts", + "apps/web/tests/conversation-view-tabs.e2e.ts", "apps/web/tests/composer-shortcuts.e2e.ts", "apps/web/tests/window-drag-coverage.e2e.ts", "apps/web/tests/complex-history.perf.ts", From 36f0bae72e94d49a0fb88d592ca2df3916627343 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 21 Oct 2026 23:45:37 +0800 Subject: [PATCH 17/85] fix(ui-primitives): document the top-strip cap for hover cards Name the Menu anchor gap once, describe the HoverCard exception in the README and JSDoc, and cover a top-side menu from an anchor in the strip. --- packages/client/ui-primitives/README.i18n.yaml | 4 ++-- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/src/HoverCard.tsx | 3 ++- packages/client/ui-primitives/src/Menu.tsx | 9 +++++---- .../ui-primitives/tests/hover-card.client.spec.tsx | 2 +- .../tests/menu-top-clearance.client.spec.tsx | 5 +++++ 7 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 6fcc3211c3..a9afc5d10e 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -21,8 +21,8 @@ en: cb874a0ca53a6747 zh: 31179193dd1d6e12 /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: - en: cac48e14b2746247 - zh: b768225ef33a44cb + en: 726d68a7e995fddb + zh: 81812e868f7610b4 /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: en: 629f57d60fe5cce1 zh: 5263447a14c21d61 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 1756a70c39..7d179ad458 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -88,7 +88,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers; an overlay opened below an anchor inside that top strip stays next to its anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists follow their anchor during dragging and CSS transforms, and stop tracking when closed. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers; while an anchor is inside that top strip, its overlay, including a `HoverCard`, is never pushed lower than the anchor's bottom edge plus the overlay's gap. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance except below an anchor inside the top strip, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists follow their anchor during dragging and CSS transforms, and stop tracking when closed. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. `Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. It stays hidden until its first fit, prefers the requested side, then tries the opposite and perpendicular sides, sliding along the anchor without covering it when a side fits. Horizontal placements keep a 10px gap; vertical placements use `gap` (8px by default). Width and height stay within a 12px viewport margin; text wraps, and content taller than the viewport scrolls when pinned. If no side fits, a stable viewport-bounded fallback can overlap the anchor. Viewport resize refreshes the anchor coordinates; label-size changes reuse them. Fitting does not synchronously measure the bubble or trigger a React render. Touch presses dismiss unpinned hints and do not trigger hover hints, including compatibility mouse events. Mouse entry opens hints only while `(any-hover: hover)` reports an available hover-capable input, excluding iOS mouse entry caused by a tapped icon changing. Keyboard focus and explicit click-to-open explanations remain available on touch-only devices. Opt-in `openOnClick` lets an informational button pin the bubble by click or tap and associates it with the anchor description. Another click or tap, Escape, Tab, or an outside pointerdown closes it. Ordinary action buttons execute their action directly without a touch tooltip. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index d5bd8a1e58..86e120d4b5 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -88,7 +88,7 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距;在该顶部区域内的锚点下方打开的浮层紧贴其锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距;锚点位于该顶部区域内时,其浮层(包括 `HoverCard`)不会被推到低于锚点底边加浮层间距的位置。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区(位于顶部区域内的锚点下方时除外),高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 `Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前保持隐藏,优先使用指定方向,再尝试相反方向和垂直于该方向的两侧;只要某一侧容得下,就沿锚点平移而不遮挡锚点。左右放置保留 10px 间距,上下放置使用 `gap`(默认 8px)。宽高限制在视口内并保留 12px 边距;文本自动换行,超出视口高度的内容在固定气泡后可滚动。若所有方向都放不下,则使用稳定的视口内回退位置,可能遮挡锚点。视口尺寸变化会重新读取锚点坐标,标签尺寸变化则复用坐标;定位不会同步测量气泡,也不会触发 React 渲染。 触摸按下会关闭未固定的提示,且不会触发悬停提示,包括触摸后的兼容鼠标事件。只有 `(any-hover: hover)` 表明存在可悬停的输入设备时,鼠标进入才会打开提示,从而排除 iOS 因已点击图标变化而生成的鼠标进入事件。仅有触摸输入的设备仍可使用键盘聚焦提示和显式点击打开的说明。信息按钮可显式启用 `openOnClick`,通过鼠标或触摸点击固定气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮在触摸点击时直接执行操作,不显示 Tooltip。 diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index e009ac98cb..f11c6bad30 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -33,7 +33,8 @@ interface CardPosition { * readable and selectable, but it carries no dismissal affordance of its own. * @param props.openDelayMs - hover dwell before the card shows (default 500). * @param props.variant - compact card beside the anchor, or a preview above/below it - * with 24px side insets, a 420px height cap, frame-top clearance, and 100ms opacity transitions. + * with 24px side insets, a 420px height cap, the frame-top clearance (capped below an anchor inside the top strip), + * and 100ms opacity transitions. * @param props.widthAnchorRef - optional element whose width and horizontal position size the preview. * @param props.inline - keep the anchor in prose; show a contained preview on hover or keyboard focus. * Its hover region connects the anchor and preview across the visual gap. diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index ded3d67afd..8bf40e9ad7 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -255,6 +255,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI } if (r === null) return const MARGIN = 12 + const GAP = 4 const vw = window.innerWidth const vh = window.innerHeight const listEl = listRef.current @@ -268,18 +269,18 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI let x: number let y: number if (side === 'right') { - x = r.right + 4 + x = r.right + GAP y = r.top } else if (align === 'start') { x = r.left - y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4 + y = side === 'bottom' ? r.bottom + GAP : r.top - lh - GAP } else { x = r.right - lw - y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4 + y = side === 'bottom' ? r.bottom + GAP : r.top - lh - GAP } if (lw > 0) x = Math.min(Math.max(x, MARGIN), vw - lw - MARGIN) - if (lh > 0) y = Math.min(Math.max(y, overlayTopMargin(MARGIN, r, 4)), vh - lh - MARGIN) + if (lh > 0) y = Math.min(Math.max(y, overlayTopMargin(MARGIN, r, GAP)), vh - lh - MARGIN) setFixedPos(current => current?.left === x && current.top === y ? current : { left: x, top: y }) } diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index 2e0ba4e709..ddc6718159 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -243,7 +243,7 @@ describe('HoverCard', () => { expect(vi.getTimerCount()).toBe(0) }) - it('keeps previews below the desktop frame clearance', () => { + it('keeps previews clear of the frame top strip except below an anchor inside it', () => { const prior = document.documentElement.style.getPropertyValue('--dsh-frame-top-clearance') const priority = document.documentElement.style.getPropertyPriority('--dsh-frame-top-clearance') document.documentElement.style.setProperty('--dsh-frame-top-clearance', '100px') diff --git a/packages/client/ui-primitives/tests/menu-top-clearance.client.spec.tsx b/packages/client/ui-primitives/tests/menu-top-clearance.client.spec.tsx index 08bc55e623..6e1700e7f9 100644 --- a/packages/client/ui-primitives/tests/menu-top-clearance.client.spec.tsx +++ b/packages/client/ui-primitives/tests/menu-top-clearance.client.spec.tsx @@ -43,6 +43,11 @@ describe('Menu top clearance', () => { expect(view.getByRole('menu').style.top).toBe('68px') }) + it('places a menu that does not fit above an anchor inside the top strip directly below it', () => { + const view = openMenu(new DOMRect(300, 14, 18, 22), 'top') + expect(view.getByRole('menu').style.top).toBe('40px') + }) + it('keeps the top strip clear when the anchor has scrolled above the viewport', () => { const view = openMenu(new DOMRect(300, -30, 18, 22), 'bottom') expect(view.getByRole('menu').style.top).toBe('68px') From 4b1d4c814e8b1b8a2e452513c62d1f4e334c347d Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 22 Oct 2026 14:32:35 +0800 Subject: [PATCH 18/85] fix(web): speed up lazy bundle loads in the Web preview The Web preview Worker converted multi-megabyte combo scripts and source maps with the buffer package's JavaScript UTF-8 codec, and the page re-encoded each source map to Base64 in JavaScript before running the bundle. Use TextEncoder/TextDecoder for whole-buffer UTF-8 conversions and FileReader for the inline source-map data URL. --- .../webworker-runtime/src/client/client.ts | 22 ++++---- .../builtin_modules/implemented/buffer.ts | 32 +++++++++++ .../tests/client/load-bundle.spec.ts | 2 +- .../tests/node/buffer.spec.ts | 53 +++++++++++++++++++ 4 files changed, 98 insertions(+), 11 deletions(-) create mode 100644 packages/experimental/webworker-runtime/tests/node/buffer.spec.ts diff --git a/packages/experimental/webworker-runtime/src/client/client.ts b/packages/experimental/webworker-runtime/src/client/client.ts index 1ac598759d..1fc91eea98 100644 --- a/packages/experimental/webworker-runtime/src/client/client.ts +++ b/packages/experimental/webworker-runtime/src/client/client.ts @@ -109,16 +109,18 @@ const REFUSAL_STATUS = 500 const encoder = new TextEncoder() const SOURCE_MAP_TRAILER = /\/\/# sourceMappingURL=([^\r\n]+)\s*$/ -const BASE64_CHUNK_BYTES = 32 * 1024 -/** Encode UTF-8 text for an inline data URL without a call-stack-sized spread. */ -function base64(value: string): string { - const bytes = encoder.encode(value) - let binary = '' - for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK_BYTES) { - binary += String.fromCharCode(...bytes.subarray(offset, offset + BASE64_CHUNK_BYTES)) - } - return btoa(binary) +/** + * Encode source-map bytes as a Base64 data URL with the browser's native encoder. + * DevTools cannot load a `blob:` source map, so the map must be inline. + */ +function sourceMapDataUrl(bytes: ArrayBuffer): Promise { + const reader = new FileReader() + return new Promise((resolve, reject) => { + reader.addEventListener('load', () => { resolve(reader.result as string) }, { once: true }) + reader.addEventListener('error', () => { reject(reader.error ?? new Error('source map encoding failed')) }, { once: true }) + reader.readAsDataURL(new Blob([bytes], { type: 'application/json;charset=utf-8' })) + }) } /** Replace a tunnel-only map reference with a self-contained Base64 data URL. */ @@ -128,7 +130,7 @@ async function localizeSourceMap(source: string, bundleUrl: string, fetch: Tunne try { const response = await fetch(new URL(match[1], new URL(bundleUrl, globalThis.location.origin))) if (!response.ok) return source.replace(SOURCE_MAP_TRAILER, '') - const dataUrl = `data:application/json;charset=utf-8;base64,${base64(await response.text())}` + const dataUrl = await sourceMapDataUrl(await response.arrayBuffer()) return source.replace(SOURCE_MAP_TRAILER, `//# sourceMappingURL=${dataUrl}`) } catch { // A source map is diagnostic-only; its transport failure must not prevent diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/buffer.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/buffer.ts index 931a582252..6ba298029e 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/buffer.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/buffer.ts @@ -2,9 +2,41 @@ * `node:buffer` for the worker, backed by the `buffer` npm package (feross), and * the matching `globalThis.Buffer` install. Node code treats Buffer as ambient, * so the global must exist before any host module evaluates. + * + * Whole-buffer UTF-8 conversions, `Buffer.from(string)` and `buf.toString()`, + * use the browser's `TextEncoder` and `TextDecoder`: they apply Node's U+FFFD + * replacement rules and run several times faster than the package's JavaScript + * codec on multi-megabyte bundles and source maps. Other encodings and ranged + * `toString` calls keep the package's implementation. */ import { Buffer, kMaxLength } from 'buffer' +const encoder = new TextEncoder() +// Node keeps a leading byte order mark in the decoded string. +const decoder = new TextDecoder('utf-8', { ignoreBOM: true }) +const UTF8 = /^utf-?8$/i +const packageFrom = Buffer.from.bind(Buffer) +const bufferPrototype = Buffer.prototype as Buffer +// oxlint-disable-next-line typescript/unbound-method -- Reflect.apply supplies the Buffer receiver. +const packageToString = bufferPrototype.toString + +Buffer.from = function from(value: unknown, encodingOrOffset?: unknown, length?: unknown): Buffer { + if (typeof value === 'string' && (encodingOrOffset === undefined || (typeof encodingOrOffset === 'string' && UTF8.test(encodingOrOffset)))) { + const bytes = encoder.encode(value) + return packageFrom(bytes.buffer, bytes.byteOffset, bytes.byteLength) + } + return Reflect.apply(packageFrom, Buffer, [value, encodingOrOffset, length]) as Buffer +} as typeof Buffer.from + +bufferPrototype.toString = function toString(this: Buffer, encoding?: BufferEncoding, start?: number, end?: number): string { + if (start === undefined && end === undefined && (encoding === undefined || UTF8.test(encoding))) { + return decoder.decode(this) + } + return Reflect.apply(packageToString, this, [encoding, start, end]) +} +// oxlint-disable-next-line typescript/unbound-method -- Installed as a Buffer method; callers supply the receiver. +Reflect.set(bufferPrototype, 'toLocaleString', bufferPrototype.toString) + Object.defineProperty(globalThis, 'Buffer', { value: Buffer, writable: true, configurable: true }) export { Buffer, kMaxLength } diff --git a/packages/experimental/webworker-runtime/tests/client/load-bundle.spec.ts b/packages/experimental/webworker-runtime/tests/client/load-bundle.spec.ts index bd0ebf2f06..bbd222c435 100644 --- a/packages/experimental/webworker-runtime/tests/client/load-bundle.spec.ts +++ b/packages/experimental/webworker-runtime/tests/client/load-bundle.spec.ts @@ -68,7 +68,7 @@ it('loads a combo map through the tunnel and embeds it in the blob script', asyn }) await vi.waitFor(() => { expect(sent).toHaveLength(2) }) expect(sent[1]?.url).toBe(`http://localhost:3000/plugins/${mapUrl}`) - const map = '{"version":3,"sections":[]}' + const map = '{"version":3,"names":["中文"],"sections":[]}' deliver({ t: 'res', id: 2, diff --git a/packages/experimental/webworker-runtime/tests/node/buffer.spec.ts b/packages/experimental/webworker-runtime/tests/node/buffer.spec.ts new file mode 100644 index 0000000000..7dc59916a6 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/node/buffer.spec.ts @@ -0,0 +1,53 @@ +/** + * Differential check of the worker's `node:buffer` UTF-8 conversions against Node. + */ +import { Buffer as NodeBuffer } from 'node:buffer' +import { afterAll, expect, it } from 'vitest' + +const ambient = globalThis.Buffer +const { Buffer: WorkerBuffer } = await import('@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtin_modules/implemented/buffer.ts') +afterAll(() => { + Object.defineProperty(globalThis, 'Buffer', { value: ambient, writable: true, configurable: true }) +}) + +const STRINGS = ['', 'a', '中文字符串', '\uFEFFbom', 'lone \uD800 high', 'lone \uDC00 low', '😀 pair', 'x'.repeat(100_000)] +const BYTES = [ + [0xEF, 0xBB, 0xBF, 0x61], + [0xC3], + [0xE4, 0xB8], + [0xF0, 0x9F, 0x98], + [0xED, 0xA0, 0x80], + [0xC0, 0x80], + [0xFF, 0xFE, 0x61], + [0x61, 0xF4, 0x90, 0x80, 0x80, 0x62], +] +// Node matches encoding names case-insensitively; its type lists lower-case names only. +const UTF8_NAMES = [undefined, 'utf8', 'utf-8', 'UTF-8'].map(name => name as BufferEncoding | undefined) + +it('encodes UTF-8 strings to the bytes Node produces', () => { + for (const value of STRINGS) { + for (const encoding of UTF8_NAMES) { + expect(WorkerBuffer.isBuffer(WorkerBuffer.from(value, encoding))).toBe(true) + expect([...WorkerBuffer.from(value, encoding)]).toEqual([...NodeBuffer.from(value, encoding)]) + } + } +}) + +it('decodes UTF-8 bytes to the string Node produces', () => { + for (const bytes of [...BYTES, ...STRINGS.map(value => [...NodeBuffer.from(value)])]) { + const buffer = WorkerBuffer.from(bytes) + for (const encoding of UTF8_NAMES) { + expect(buffer.toString(encoding)).toBe(NodeBuffer.from(bytes).toString(encoding)) + } + expect(buffer.toLocaleString()).toBe(NodeBuffer.from(bytes).toString()) + } +}) + +it('keeps the package conversions for other encodings and ranges', () => { + expect([...WorkerBuffer.from('ÿa', 'latin1')]).toEqual([0xFF, 0x61]) + expect([...WorkerBuffer.from('ff61', 'hex')]).toEqual([0xFF, 0x61]) + expect(WorkerBuffer.from('中文').toString('hex')).toBe(NodeBuffer.from('中文').toString('hex')) + expect(WorkerBuffer.from('a中b').toString('utf8', 1, 4)).toBe('中') + const view = new Uint8Array([0x61, 0x62, 0x63]).buffer + expect(WorkerBuffer.from(view, 1, 1).toString()).toBe('b') +}) From da7996de07c0ce4cfe1902ae0648cf52ef3c9891 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 22 Oct 2026 14:56:35 +0800 Subject: [PATCH 19/85] fix(schedule): report bulk removal outcomes from the page --- packages/client/ui-schedule/src/client/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-schedule/src/client/index.ts b/packages/client/ui-schedule/src/client/index.ts index b3d98b6bee..080fed2305 100644 --- a/packages/client/ui-schedule/src/client/index.ts +++ b/packages/client/ui-schedule/src/client/index.ts @@ -192,7 +192,10 @@ export function apply(ctx: ClientContext): void { ...detail, // The page has no creation form: a new reminder starts in a Session. onNewTask: () => { ctx.uiWorkspace.startSession() }, - onClearEnded: clearEnded, + onClearEnded: async () => { + const outcome = await clearEnded() + deleteToast.reportClear(outcome) + }, }), }, TaskManagerPage)) yield ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ From 39d973322b2650d81472c92a38bd0f9679c36cba Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 22 Oct 2026 15:13:05 +0800 Subject: [PATCH 20/85] test(web): compare the worker Buffer with an unmodified Node reference Vitest resolves the shim's `buffer` import to Node's built-in module, so the differential spec patched and then compared the same implementation. Mock node:buffer with the npm package the Worker bundle uses. Also keep the UTF-8 encoding check symmetric, call the package methods directly, and document that the patch applies to every Buffer user in the Worker. --- .../builtin_modules/implemented/buffer.ts | 30 +++++++++++-------- .../tests/node/buffer.spec.ts | 15 ++++++++-- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/buffer.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/buffer.ts index 6ba298029e..64aefd9cb5 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/buffer.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/buffer.ts @@ -3,11 +3,12 @@ * the matching `globalThis.Buffer` install. Node code treats Buffer as ambient, * so the global must exist before any host module evaluates. * - * Whole-buffer UTF-8 conversions, `Buffer.from(string)` and `buf.toString()`, - * use the browser's `TextEncoder` and `TextDecoder`: they apply Node's U+FFFD - * replacement rules and run several times faster than the package's JavaScript - * codec on multi-megabyte bundles and source maps. Other encodings and ranged - * `toString` calls keep the package's implementation. + * Loading this module replaces `Buffer.from`, `Buffer.prototype.toString`, and + * `toLocaleString` on the package's class, so every Buffer user in the Worker, + * including bundled third-party code, gets the same conversions. Whole-buffer + * UTF-8 conversions use `TextEncoder` and `TextDecoder` with Node's U+FFFD + * replacement and BOM retention; other encodings and ranged `toString` calls + * keep the package's implementation. */ import { Buffer, kMaxLength } from 'buffer' @@ -15,24 +16,27 @@ const encoder = new TextEncoder() // Node keeps a leading byte order mark in the decoded string. const decoder = new TextDecoder('utf-8', { ignoreBOM: true }) const UTF8 = /^utf-?8$/i -const packageFrom = Buffer.from.bind(Buffer) +const packageFrom = Buffer.from.bind(Buffer) as (value: unknown, encodingOrOffset?: unknown, length?: unknown) => Buffer const bufferPrototype = Buffer.prototype as Buffer -// oxlint-disable-next-line typescript/unbound-method -- Reflect.apply supplies the Buffer receiver. +// oxlint-disable-next-line typescript/unbound-method -- Called with .call and a Buffer receiver. const packageToString = bufferPrototype.toString +/** Whether an omitted or string encoding argument names UTF-8. */ +function isUtf8(encoding: unknown): boolean { + return encoding === undefined || (typeof encoding === 'string' && UTF8.test(encoding)) +} + Buffer.from = function from(value: unknown, encodingOrOffset?: unknown, length?: unknown): Buffer { - if (typeof value === 'string' && (encodingOrOffset === undefined || (typeof encodingOrOffset === 'string' && UTF8.test(encodingOrOffset)))) { + if (typeof value === 'string' && isUtf8(encodingOrOffset)) { const bytes = encoder.encode(value) return packageFrom(bytes.buffer, bytes.byteOffset, bytes.byteLength) } - return Reflect.apply(packageFrom, Buffer, [value, encodingOrOffset, length]) as Buffer + return packageFrom(value, encodingOrOffset, length) } as typeof Buffer.from bufferPrototype.toString = function toString(this: Buffer, encoding?: BufferEncoding, start?: number, end?: number): string { - if (start === undefined && end === undefined && (encoding === undefined || UTF8.test(encoding))) { - return decoder.decode(this) - } - return Reflect.apply(packageToString, this, [encoding, start, end]) + if (start === undefined && end === undefined && isUtf8(encoding)) return decoder.decode(this) + return packageToString.call(this, encoding, start, end) } // oxlint-disable-next-line typescript/unbound-method -- Installed as a Buffer method; callers supply the receiver. Reflect.set(bufferPrototype, 'toLocaleString', bufferPrototype.toString) diff --git a/packages/experimental/webworker-runtime/tests/node/buffer.spec.ts b/packages/experimental/webworker-runtime/tests/node/buffer.spec.ts index 7dc59916a6..20ee6c9f77 100644 --- a/packages/experimental/webworker-runtime/tests/node/buffer.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/buffer.spec.ts @@ -1,8 +1,14 @@ /** * Differential check of the worker's `node:buffer` UTF-8 conversions against Node. */ -import { Buffer as NodeBuffer } from 'node:buffer' -import { afterAll, expect, it } from 'vitest' +import { createRequire } from 'node:module' +import { afterAll, expect, it, vi } from 'vitest' + +// The worker bundle resolves `buffer` to the npm package, while Vitest maps it to Node's built-in module. +// Mock that module so the shim patches the npm package and Node's own Buffer stays the unmodified reference. +const require = createRequire(import.meta.url) +const NodeBuffer = (require('node:buffer') as typeof import('node:buffer')).Buffer +vi.mock('node:buffer', () => require('buffer/') as object) const ambient = globalThis.Buffer const { Buffer: WorkerBuffer } = await import('@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtin_modules/implemented/buffer.ts') @@ -10,6 +16,11 @@ afterAll(() => { Object.defineProperty(globalThis, 'Buffer', { value: ambient, writable: true, configurable: true }) }) +it('tests the npm package against an unmodified Node reference', () => { + expect(WorkerBuffer).not.toBe(NodeBuffer) + expect(NodeBuffer.from('\uFEFF').toString()).toBe('\uFEFF') +}) + const STRINGS = ['', 'a', '中文字符串', '\uFEFFbom', 'lone \uD800 high', 'lone \uDC00 low', '😀 pair', 'x'.repeat(100_000)] const BYTES = [ [0xEF, 0xBB, 0xBF, 0x61], From 6db50418120068f320580a19a370a3eae6a13ab3 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 22 Oct 2026 17:54:21 +0800 Subject: [PATCH 21/85] fix(storage): keep batch deletion tracked and consistent --- docs/subsystems/storage.i18n.yaml | 4 +- docs/subsystems/storage.md | 2 +- docs/subsystems/storage.zh.md | 2 +- packages/storage/storage-domain/src/domain.ts | 11 +- .../storage-domain/tests/domain.spec.ts | 13 +++ .../storage/storage-json/README.i18n.yaml | 4 +- packages/storage/storage-json/README.md | 2 +- packages/storage/storage-json/README.zh.md | 2 +- .../storage-json/src/per-record-unit.ts | 45 ++++++-- .../tests/per-record-batch.spec.ts | 107 ++++++++++++++++++ packages/storage/storage/src/backend.ts | 9 +- packages/storage/storage/src/error.ts | 1 + 12 files changed, 181 insertions(+), 21 deletions(-) create mode 100644 packages/storage/storage-json/tests/per-record-batch.spec.ts diff --git a/docs/subsystems/storage.i18n.yaml b/docs/subsystems/storage.i18n.yaml index 2c1b8df95a..7c01f2b64d 100644 --- a/docs/subsystems/storage.i18n.yaml +++ b/docs/subsystems/storage.i18n.yaml @@ -9,8 +9,8 @@ en: b11e06e7c0e0fd13 zh: 4585f26ff416cb41 /storage/the-backend-contract: - en: 8b8cc57b66f4ff87 - zh: aecf14a900014fc1 + en: b83300f10cc822f2 + zh: 61835181022a7ba6 /storage/declaring-a-domain: en: 775cce941500f01f zh: 78acbc67f48574cb diff --git a/docs/subsystems/storage.md b/docs/subsystems/storage.md index 1fcd252e80..165be01812 100644 --- a/docs/subsystems/storage.md +++ b/docs/subsystems/storage.md @@ -44,7 +44,7 @@ interface StorageBackend { } ``` -A backend owns one medium (a file-tree root, a database file) and exposes optional operation groups; `kv` is the only shipped group. `KvFacet.open(descriptor)` opens one named unit — `KvUnitDescriptor` carries the name, current format version, optional compatible record versions, table names, and whether a global singleton slot exists — and returns a `KvUnit` with `loadAll`, `putRecord`, `deleteRecord`, `deleteRecords`, `setGlobal`, and `close`. Unit and table names must match `UNIT_NAME_RE` (safe as a file name and as a SQL identifier segment); record keys are arbitrary strings that never reach file paths. A unit does not serialize concurrent writes — ordering belongs to the caller — but each single call is atomic on the medium and durable once resolved. `deleteRecords` applies a whole batch in one call: it resolves once every listed record is durably absent, and a rejection leaves the batch unapplied. A `single` medium stamped with a different version rejects `version-mismatch`; a `per-record` document stamped outside the accepted set reads as absent. A medium that cannot be parsed as the unit rejects `malformed-medium`. [`backend.ts`](../../packages/storage/storage/src/backend.ts) is the normative clause-by-clause contract, and the shared conformance suite in [`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) checks every clause against each backend. The [json backend](../../packages/storage/storage-json/README.md) republishes one whole human-readable file per unit atomically; the [sqlite backend](../../packages/storage/storage-sqlite/README.md) stores one document per row in one database for frequently updated data. +A backend owns one medium (a file-tree root, a database file) and exposes optional operation groups; `kv` is the only shipped group. `KvFacet.open(descriptor)` opens one named unit — `KvUnitDescriptor` carries the name, current format version, optional compatible record versions, table names, and whether a global singleton slot exists — and returns a `KvUnit` with `loadAll`, `putRecord`, `deleteRecord`, `deleteRecords`, `setGlobal`, and `close`. Unit and table names must match `UNIT_NAME_RE` (safe as a file name and as a SQL identifier segment); record keys are arbitrary strings that never reach file paths. A unit does not serialize concurrent writes — ordering belongs to the caller — but each single call is atomic on the medium and durable once resolved. `deleteRecords` applies a whole batch in one call: it resolves once every listed record is durably absent, and a rejection leaves the batch unapplied; a backend that cannot restore staged records rejects with `unknown-state` and then rejects every later operation on that unit. A `single` medium stamped with a different version rejects `version-mismatch`; a `per-record` document stamped outside the accepted set reads as absent. A medium that cannot be parsed as the unit rejects `malformed-medium`. [`backend.ts`](../../packages/storage/storage/src/backend.ts) is the normative clause-by-clause contract, and the shared conformance suite in [`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) checks every clause against each backend. The [json backend](../../packages/storage/storage-json/README.md) republishes one whole human-readable file per unit atomically; the [sqlite backend](../../packages/storage/storage-sqlite/README.md) stores one document per row in one database for frequently updated data. ## Declaring a domain diff --git a/docs/subsystems/storage.zh.md b/docs/subsystems/storage.zh.md index 62afa47835..03d03b08a7 100644 --- a/docs/subsystems/storage.zh.md +++ b/docs/subsystems/storage.zh.md @@ -44,7 +44,7 @@ interface StorageBackend { } ``` -一个后端拥有一个介质(一棵文件树的根目录、一个数据库文件),并提供可选的操作组;`kv` 是唯一已交付的操作组。`KvFacet.open(descriptor)` 打开一个具名 unit——`KvUnitDescriptor` 携带名称、当前格式版本、可选的兼容记录版本、表名清单,以及是否存在全局单例 slot——并返回提供 `loadAll`、`putRecord`、`deleteRecord`、`deleteRecords`、`setGlobal` 和 `close` 的 `KvUnit`。unit 名与表名必须匹配 `UNIT_NAME_RE`(既可安全用作文件名,也可安全用作 SQL 标识符片段);记录键是任意字符串,绝不进入文件路径。unit 不对并发写入做串行化——顺序由调用方负责——但每次单独调用在介质上都是原子的,且 resolve 后即已持久。`deleteRecords` 以一次调用应用整批删除:所有列出的记录都持久缺失后才 resolve,拒绝时整批未应用。`single` 介质上记录的版本不同时拒绝 `version-mismatch`;`per-record` 文档的版本在接受集合之外时读作不存在。无法按该 unit 解析的介质拒绝 `malformed-medium`。[`backend.ts`](../../packages/storage/storage/src/backend.ts) 是逐条款的规范性约定,[`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) 中的共享一致性套件会针对每个后端检查每项条款。[json 后端](../../packages/storage/storage-json/README.zh.md)以原子方式为每个 unit 整文件重新发布一份人类可读文件;[sqlite 后端](../../packages/storage/storage-sqlite/README.zh.md)在单个数据库中每行存储一份文档,用于频繁更新的数据。 +一个后端拥有一个介质(一棵文件树的根目录、一个数据库文件),并提供可选的操作组;`kv` 是唯一已交付的操作组。`KvFacet.open(descriptor)` 打开一个具名 unit——`KvUnitDescriptor` 携带名称、当前格式版本、可选的兼容记录版本、表名清单,以及是否存在全局单例 slot——并返回提供 `loadAll`、`putRecord`、`deleteRecord`、`deleteRecords`、`setGlobal` 和 `close` 的 `KvUnit`。unit 名与表名必须匹配 `UNIT_NAME_RE`(既可安全用作文件名,也可安全用作 SQL 标识符片段);记录键是任意字符串,绝不进入文件路径。unit 不对并发写入做串行化——顺序由调用方负责——但每次单独调用在介质上都是原子的,且 resolve 后即已持久。`deleteRecords` 以一次调用应用整批删除:所有列出的记录都持久缺失后才 resolve,拒绝时整批未应用;无法恢复暂存记录的批量删除会以 `unknown-state` 拒绝,并让该 unit 之后的所有操作都拒绝。`single` 介质上记录的版本不同时拒绝 `version-mismatch`;`per-record` 文档的版本在接受集合之外时读作不存在。无法按该 unit 解析的介质拒绝 `malformed-medium`。[`backend.ts`](../../packages/storage/storage/src/backend.ts) 是逐条款的规范性约定,[`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) 中的共享一致性套件会针对每个后端检查每项条款。[json 后端](../../packages/storage/storage-json/README.zh.md)以原子方式为每个 unit 整文件重新发布一份人类可读文件;[sqlite 后端](../../packages/storage/storage-sqlite/README.zh.md)在单个数据库中每行存储一份文档,用于频繁更新的数据。 ## 声明领域 diff --git a/packages/storage/storage-domain/src/domain.ts b/packages/storage/storage-domain/src/domain.ts index be8f6b0298..38ac7b7bfe 100644 --- a/packages/storage/storage-domain/src/domain.ts +++ b/packages/storage/storage-domain/src/domain.ts @@ -84,8 +84,11 @@ export interface KvTable { * * The batch is one job on the domain's write chain: it runs at its slot, not * at call time, so an earlier queued write of the same domain is visible to - * it and no other write interleaves. Missing keys are ignored; a backend - * failure rejects with the batch unapplied, leaving every record in place. + * it and no other write interleaves. Missing keys are ignored. The in-memory + * records reach the committed state before any `domain/changed` listener + * runs. A backend failure rejects with the batch unapplied, leaving every + * record in place; a backend that cannot restore its staged records rejects + * with `unknown-state` and keeps its unit unusable. * @param keys - Record keys; duplicates are harmless. * @returns the number of keys that existed and were removed. */ @@ -348,8 +351,10 @@ class KvTableImpl implements KvTable { const present = [...new Set(keys)].filter(key => this.records.has(key)) if (present.length === 0) return 0 await this.host.unit.deleteRecords(this.tableName, present) + // Memory reaches the committed batch state before the first listener runs, + // so every `domain/changed` observes the same removed set as the medium. + for (const key of present) this.records.delete(key) for (const key of present) { - this.records.delete(key) this.host.emitChanged({ domain: this.host.domainName, table: this.tableName, diff --git a/packages/storage/storage-domain/tests/domain.spec.ts b/packages/storage/storage-domain/tests/domain.spec.ts index 9762682eb2..9c99cd0fab 100644 --- a/packages/storage/storage-domain/tests/domain.spec.ts +++ b/packages/storage/storage-domain/tests/domain.spec.ts @@ -295,6 +295,19 @@ describe('KvTable writes', () => { expect(changes).toHaveLength(seen + 2) }) + it('applies the whole batch to memory before announcing it', async () => { + const { ctx, facility } = await harness() + const domain = await facility.open(spec) + const table = domain.table('items') + await table.put('a', { label: 'x', count: 1 }) + await table.put('b', { label: 'y', count: 2 }) + const observed: string[][] = [] + ctx.on('domain/changed', () => { observed.push([...table.keys()].sort()) }) + await table.deleteMany(['a', 'b']) + // Every listener run sees the committed batch state, not a prefix of it. + expect(observed).toEqual([[], []]) + }) + it('emits domain/changed per durable write, in order, with tombstones and global marker', async () => { const { facility, changes } = await harness() const domain = await facility.open(spec) diff --git a/packages/storage/storage-json/README.i18n.yaml b/packages/storage/storage-json/README.i18n.yaml index 3c292a3da1..cfff00f5b3 100644 --- a/packages/storage/storage-json/README.i18n.yaml +++ b/packages/storage/storage-json/README.i18n.yaml @@ -30,8 +30,8 @@ en: b7d222309b3e5438 zh: 807c7314ee341d94 /deepseek-ai-dsh-storage-json/understand-the-implementation/design-concept: - en: 3139c95cf26c40e2 - zh: 215e88a688869cbe + en: d4f84e0e5d2b3770 + zh: 87eca52475ea2fe6 /deepseek-ai-dsh-storage-json/understand-the-implementation/file-formats: en: b3de0a697db4f916 zh: 56b26f7823392bd5 diff --git a/packages/storage/storage-json/README.md b/packages/storage/storage-json/README.md index 3d8665bb34..55f50d666e 100644 --- a/packages/storage/storage-json/README.md +++ b/packages/storage/storage-json/README.md @@ -70,7 +70,7 @@ The two layouts share atomic publication but assign state ownership differently. ### Design concept - **`single` keeps memory authoritative.** Each write changes the in-memory unit, serializes its complete state, and atomically replaces `.json`. A failed publish restores the prior in-memory value. A batch delete is one such publish: the whole batch is applied or restored together. -- **`per-record` keeps the directory authoritative.** Each put or delete changes one `/
/.json` document, and `loadAll()` rereads the tree. A batch delete renames the batch's documents out of the readable set, restores them if a rename fails, and unlinks the staged copies afterwards. Each document stamps the unit version and carries one record value. +- **`per-record` keeps the directory authoritative.** Each put or delete changes one `/
/.json` document, and `loadAll()` rereads the tree. A batch delete renames the batch's documents out of the readable set, restores them if a rename fails, and unlinks the staged copies afterwards. If that restoration fails, the unit rejects with `unknown-state` and every later operation on it rejects. Each document stamps the unit version and carries one record value. - **Publication is durable per call.** A write uses a temporary file, fsync, atomic `rename()` replacement, and a parent-directory fsync on POSIX. The domain layer's write chain supplies ordering across calls. ### File formats diff --git a/packages/storage/storage-json/README.zh.md b/packages/storage/storage-json/README.zh.md index d611a65666..b59d7dbdea 100644 --- a/packages/storage/storage-json/README.zh.md +++ b/packages/storage/storage-json/README.zh.md @@ -70,7 +70,7 @@ kind: "package-reference" ### 设计理念 - **`single` 以内存为权威状态。** 每次写入都会更改内存单元、序列化其完整状态,并以原子方式替换 `.json`。发布失败会恢复先前的内存值。批量删除就是这样一次发布:整批一起应用或一起恢复。 -- **`per-record` 以目录为权威状态。** 每次 put 或 delete 都会更改一个 `/
/.json` 文档,`loadAll()` 则重新读取目录树。每份文档都带有单元版本戳与一条记录值。批量删除先把整批文档改名移出可读集合,任一次改名失败就恢复,之后删除暂存副本。 +- **`per-record` 以目录为权威状态。** 每次 put 或 delete 都会更改一个 `/
/.json` 文档,`loadAll()` 则重新读取目录树。每份文档都带有单元版本戳与一条记录值。批量删除先把整批文档改名移出可读集合,任一次改名失败就恢复,之后删除暂存副本;恢复本身失败时该 unit 以 `unknown-state` 拒绝,之后对它的所有操作都会拒绝。 - **每次调用都持久发布。** 写入过程使用临时文件、fsync、原子 `rename()` 替换,并在 POSIX 上 fsync 父目录。领域层写入链负责安排跨调用的顺序。 ### 文件格式 diff --git a/packages/storage/storage-json/src/per-record-unit.ts b/packages/storage/storage-json/src/per-record-unit.ts index b0a86537c5..f4debdbc44 100644 --- a/packages/storage/storage-json/src/per-record-unit.ts +++ b/packages/storage/storage-json/src/per-record-unit.ts @@ -195,6 +195,8 @@ async function readRecord(path: string, versions: readonly number[]): Promise>() @@ -233,21 +235,35 @@ export class PerRecordJsonUnit implements KvUnit { * Durably delete many records as one batch. Documents are renamed out of the * readable set first; only after the whole batch is staged are the staged * copies unlinked. A failure while staging restores the documents it moved, - * so a rejection leaves the batch unapplied. A staged document a crash leaves - * behind is not readable (`open` reads `*.json` documents only). + * so a rejection normally leaves the batch unapplied. If that restoration + * itself fails, the readable state is unknown: the unit rejects with + * `unknown-state` and every later operation on it rejects, while `close` + * still releases it. A staged document a crash leaves behind is not readable + * (`open` reads `*.json` documents only). */ async deleteRecords(table: string, keys: readonly string[]): Promise { this.assertOpen() const dir = this.tableDir(table) const unique = [...new Set(keys)] for (const key of unique) assertSafeKey(this.descriptor.name, key) + // One tracked promise covers staging, restoration, and cleanup, so close() + // cannot release the unit while any part of the batch is still running. + await this.tracked(this.removeDocuments(dir, unique)) + } + + /** + * Remove one batch's documents under a single tracked promise. + * @param dir - Declared table directory. + * @param keys - Validated, de-duplicated record keys. + */ + private async removeDocuments(dir: string, keys: readonly string[]): Promise { const staged: Array<{ from: string; to: string }> = [] try { - for (const key of unique) { + for (const key of keys) { const from = join(dir, `${key}.json`) const to = `${from}.deleting` try { - await this.tracked(rename(from, to)) + await rename(from, to) } catch (error: unknown) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue throw error @@ -255,12 +271,26 @@ export class PerRecordJsonUnit implements KvUnit { staged.push({ from, to }) } } catch (error: unknown) { - await Promise.allSettled(staged.map(({ from, to }) => rename(to, from))) - throw error + const restoreFailures: unknown[] = [] + for (const { from, to } of staged) { + try { + await rename(to, from) + } catch (restoreError: unknown) { + restoreFailures.push(restoreError) + } + } + if (restoreFailures.length === 0) throw error + this.unusable = new StorageError( + 'unknown-state', + `unit '${this.descriptor.name}' could not restore ${restoreFailures.length} staged document(s)` + + ' after a failed batch delete; its readable state is unknown', + { cause: new AggregateError([error, ...restoreFailures], 'batch delete failed and restoration failed') }, + ) + throw this.unusable } // The renames already removed the batch from the readable set; unlinking is // cleanup, and a failed unlink leaves an unreadable file rather than an entry. - await Promise.allSettled(staged.map(({ to }) => this.tracked(rm(to, { force: true })))) + await Promise.allSettled(staged.map(({ to }) => rm(to, { force: true }))) } /** @@ -304,6 +334,7 @@ export class PerRecordJsonUnit implements KvUnit { if (this.closed) { throw new StorageError('closed', `unit '${this.descriptor.name}' is closed`) } + if (this.unusable !== undefined) throw this.unusable } /* jscpd:ignore-end */ diff --git a/packages/storage/storage-json/tests/per-record-batch.spec.ts b/packages/storage/storage-json/tests/per-record-batch.spec.ts new file mode 100644 index 0000000000..884d992347 --- /dev/null +++ b/packages/storage/storage-json/tests/per-record-batch.spec.ts @@ -0,0 +1,107 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { JsonStorageBackend } from '../src/index.ts' + +/** Rename hook installed by a case; returning true means the case handled the call. */ +const gates = vi.hoisted(() => ({ + renameOverride: null as ((from: string, to: string) => Promise) | null, +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + rename: async ( + from: Parameters[0], + to: Parameters[1], + ): Promise => { + const override = gates.renameOverride + if (override !== null && await override(String(from), String(to))) return + await actual.rename(from, to) + }, + } +}) + +const DESCRIPTOR = { name: 'batch', version: 1, layout: 'per-record' as const, tables: ['t'], hasGlobal: false } + +const roots: string[] = [] +async function freshRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'dsh-per-record-batch-')) + roots.push(root) + return root +} + +afterEach(async () => { + gates.renameOverride = null + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) + +/** A filesystem denial the batch must treat as an inability to restore. */ +function denied(): NodeJS.ErrnoException { + return Object.assign(new Error('denied'), { code: 'EACCES' }) +} + +it('waits for the whole batch before close releases the unit', async () => { + const root = await freshRoot() + const backend: JsonStorageBackend = new JsonStorageBackend(root) + const unit = await backend.kv.open(DESCRIPTOR) + await unit.putRecord('t', 'a', { v: 1 }) + await unit.putRecord('t', 'b', { v: 2 }) + + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + gates.renameOverride = async (_from, to) => { + if (to.endsWith('/b.json.deleting')) { + entered.resolve(undefined) + await release.promise + } + return false + } + + const batch = unit.deleteRecords('t', ['a', 'b']) + await entered.promise + let closed = false + const closing = unit.close().then(() => { closed = true }) + await new Promise(resolve => setTimeout(resolve, 25)) + // The batch still holds the unit: its rename is blocked inside the call. + expect(closed).toBe(false) + + release.resolve(undefined) + await batch + await closing + expect(closed).toBe(true) + + // The batch finished before release, so the reopened unit sees no records. + const reopened = await backend.kv.open(DESCRIPTOR) + expect(await reopened.loadAll()).toEqual({ tables: { t: {} }, global: null }) + await backend.close() +}) + +it('rejects with unknown-state when a failed batch cannot restore its staged documents', async () => { + const root = await freshRoot() + const backend: JsonStorageBackend = new JsonStorageBackend(root) + const unit = await backend.kv.open(DESCRIPTOR) + await unit.putRecord('t', 'a', { v: 1 }) + await unit.putRecord('t', 'b', { v: 2 }) + + gates.renameOverride = async (from, to) => { + // The second document cannot be staged, and the first cannot be restored. + if (to.endsWith('/b.json.deleting')) throw denied() + if (from.endsWith('/a.json.deleting')) throw denied() + return false + } + + await expect(unit.deleteRecords('t', ['a', 'b'])).rejects.toMatchObject({ + name: 'StorageError', + code: 'unknown-state', + }) + // The unit reports the unknown state instead of claiming the batch is unapplied. + await expect(unit.loadAll()).rejects.toMatchObject({ code: 'unknown-state' }) + await expect(unit.putRecord('t', 'c', { v: 3 })).rejects.toMatchObject({ code: 'unknown-state' }) + await expect(unit.deleteRecord('t', 'a')).rejects.toMatchObject({ code: 'unknown-state' }) + // Releasing the handle stays possible. + await unit.close() + await backend.close() +}) diff --git a/packages/storage/storage/src/backend.ts b/packages/storage/storage/src/backend.ts index a483b4dc59..7dc1d1a9ba 100644 --- a/packages/storage/storage/src/backend.ts +++ b/packages/storage/storage/src/backend.ts @@ -113,9 +113,12 @@ export interface KvUnit { * Delete several records durably as one batch. Idempotent: missing keys are * no-ops. Resolves once every listed record is absent from the readable * state and durable; a rejection leaves the batch unapplied, so no listed - * record was removed. A backend whose medium can apply a whole batch in one - * operation (the `single` document, a row store) must do that instead of - * repeating {@link deleteRecord} per key. + * record was removed. A backend that stages records and then cannot restore + * them rejects with `unknown-state` instead: the readable state is then + * unknown and every later operation on that unit rejects with the same + * error, while {@link close} still releases the unit. A backend whose medium + * can apply a whole batch in one operation (the `single` document, a row + * store) must do that instead of repeating {@link deleteRecord} per key. * @param table - Declared table name. * @param keys - Record keys; duplicates are harmless. In the `per-record` * layout every key becomes a path segment and must match `[a-zA-Z0-9_-]+` diff --git a/packages/storage/storage/src/error.ts b/packages/storage/storage/src/error.ts index 9e3424db55..90065fa90e 100644 --- a/packages/storage/storage/src/error.ts +++ b/packages/storage/storage/src/error.ts @@ -11,6 +11,7 @@ export type StorageErrorCode = | 'duplicate-mount' | 'version-mismatch' | 'malformed-medium' + | 'unknown-state' | 'closed' /** From 0397bd3f04d6783d5991cfdb6f776c4a473f2f4f Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 22 Oct 2026 18:21:16 +0800 Subject: [PATCH 22/85] fix(storage): quarantine domains after unknown-state batch failures --- docs/subsystems/storage.i18n.yaml | 4 +- docs/subsystems/storage.md | 2 +- docs/subsystems/storage.zh.md | 2 +- .../storage/storage-domain/README.i18n.yaml | 4 +- packages/storage/storage-domain/README.md | 2 +- packages/storage/storage-domain/README.zh.md | 2 +- packages/storage/storage-domain/src/domain.ts | 23 +++++++-- .../storage-domain/tests/domain.spec.ts | 47 +++++++++++++++++-- .../tests/per-record-batch.spec.ts | 43 +++++++++++------ 9 files changed, 99 insertions(+), 30 deletions(-) diff --git a/docs/subsystems/storage.i18n.yaml b/docs/subsystems/storage.i18n.yaml index 7c01f2b64d..5a59dee93e 100644 --- a/docs/subsystems/storage.i18n.yaml +++ b/docs/subsystems/storage.i18n.yaml @@ -15,8 +15,8 @@ en: 775cce941500f01f zh: 78acbc67f48574cb /storage/the-open-domain: - en: 65b94c4c8d240a77 - zh: 337d9e36a9bce113 + en: b69b25bc06f1c052 + zh: 8aca58718da520c7 /storage/the-domain-facility-ctx-storagedomain: en: deed74fcd4506ef4 zh: 8e3e6e5d2dc2e989 diff --git a/docs/subsystems/storage.md b/docs/subsystems/storage.md index 165be01812..1a86fc83ba 100644 --- a/docs/subsystems/storage.md +++ b/docs/subsystems/storage.md @@ -123,7 +123,7 @@ interface Domain { } ``` -Reads are synchronous from authoritative in-memory state: `KvTable` exposes `get`/`entries`/`keys`/`size` (snapshot iterators that stay stable while queued writes land), and the global handle's `get()` serves the spec's `initial` until the first `set` materializes the slot on the medium. Every write — `put`, `delete`, `deleteMany`, `update`, `global.set` — queues on one per-domain chain and reaches backend durability first, then mutates memory, then emits `domain/changed`; a rejected backend write leaves memory untouched, so reads never diverge from the medium. `update(key, fn)` is an atomic read-modify-write at its chain slot (a missing key rejects `missing-key`); `delete` of an absent key resolves `false` with no write and no event. `deleteMany(keys)` removes the present keys as one backend batch in one chain slot, resolves the removed count, and emits one `domain/changed` per removed key; a rejected backend write leaves every record in place and emits nothing. Returned records are the stored objects themselves, not copies — replace via `put`/`update`, never mutate in place. +Reads are synchronous from authoritative in-memory state: `KvTable` exposes `get`/`entries`/`keys`/`size` (snapshot iterators that stay stable while queued writes land), and the global handle's `get()` serves the spec's `initial` until the first `set` materializes the slot on the medium. Every write — `put`, `delete`, `deleteMany`, `update`, `global.set` — queues on one per-domain chain and reaches backend durability first, then mutates memory, then emits `domain/changed`; a rejected backend write leaves memory untouched, so reads never diverge from the medium; a backend that reports `unknown-state` instead quarantines the domain, and its reads and later writes reject until `close()` releases it. `update(key, fn)` is an atomic read-modify-write at its chain slot (a missing key rejects `missing-key`); `delete` of an absent key resolves `false` with no write and no event. `deleteMany(keys)` removes the present keys as one backend batch in one chain slot, resolves the removed count, and emits one `domain/changed` per removed key; a rejected backend write leaves every record in place and emits nothing; an `unknown-state` rejection quarantines the domain. Returned records are the stored objects themselves, not copies — replace via `put`/`update`, never mutate in place. ## The domain facility: `ctx.storageDomain` diff --git a/docs/subsystems/storage.zh.md b/docs/subsystems/storage.zh.md index 03d03b08a7..e0461f91ce 100644 --- a/docs/subsystems/storage.zh.md +++ b/docs/subsystems/storage.zh.md @@ -123,7 +123,7 @@ interface Domain { } ``` -读取是同步的,来自权威的内存态:`KvTable` 暴露 `get`/`entries`/`keys`/`size`(快照迭代器,在排队写入落地期间保持稳定),global 句柄的 `get()` 在第一次 `set` 将 slot 物化到介质之前一直返回 spec 的 `initial`。每次写入——`put`、`delete`、`deleteMany`、`update`、`global.set`——都在同一条逐领域写链上排队,先在后端完成持久化,再更新内存,最后发出 `domain/changed`;后端写入被拒时内存原样不动,因此读取绝不会偏离介质。`update(key, fn)` 在其写链 slot 上是一次原子的读-改-写(键缺失时拒绝 `missing-key`);`delete` 一个不存在的键 resolve 为 `false`,不产生写入也不产生事件。`deleteMany(keys)` 在一个写链 slot 内把存在的键作为一次后端批量删除移除,resolve 为移除数量,并为每个被移除的键发一次 `domain/changed`;后端写入被拒时所有记录保持原样,也不发事件。返回的记录就是存储的对象本身,不是副本——请经 `put`/`update` 整体替换,绝不要就地修改。 +读取是同步的,来自权威的内存态:`KvTable` 暴露 `get`/`entries`/`keys`/`size`(快照迭代器,在排队写入落地期间保持稳定),global 句柄的 `get()` 在第一次 `set` 将 slot 物化到介质之前一直返回 spec 的 `initial`。每次写入——`put`、`delete`、`deleteMany`、`update`、`global.set`——都在同一条逐领域写链上排队,先在后端完成持久化,再更新内存,最后发出 `domain/changed`;后端写入被拒时内存原样不动,因此读取绝不会偏离介质;若后端报告 `unknown-state`,领域会被隔离,读取与后续写入都会拒绝,直到 `close()` 释放它。`update(key, fn)` 在其写链 slot 上是一次原子的读-改-写(键缺失时拒绝 `missing-key`);`delete` 一个不存在的键 resolve 为 `false`,不产生写入也不产生事件。`deleteMany(keys)` 在一个写链 slot 内把存在的键作为一次后端批量删除移除,resolve 为移除数量,并为每个被移除的键发一次 `domain/changed`;后端写入被拒时所有记录保持原样,也不发事件;`unknown-state` 拒绝会隔离整个领域。返回的记录就是存储的对象本身,不是副本——请经 `put`/`update` 整体替换,绝不要就地修改。 ## 领域 facility:`ctx.storageDomain` diff --git a/packages/storage/storage-domain/README.i18n.yaml b/packages/storage/storage-domain/README.i18n.yaml index 29d392dac9..ff5b580abf 100644 --- a/packages/storage/storage-domain/README.i18n.yaml +++ b/packages/storage/storage-domain/README.i18n.yaml @@ -36,8 +36,8 @@ en: 1cc8b33834457c38 zh: c549afc7c4960e9e /deepseek-ai-dsh-storage-domain/understand-the-implementation/design-concept: - en: 899fd5b09d9eb3c4 - zh: 771eb56d143cca5b + en: 4dffdacca3d2cd9c + zh: b41875243c4a1c0b /deepseek-ai-dsh-storage-domain/understand-the-implementation/open-sequence: en: 052da5d6c45205ca zh: 724c875e3c431fee diff --git a/packages/storage/storage-domain/README.md b/packages/storage/storage-domain/README.md index fc9d206aac..56e9069bd2 100644 --- a/packages/storage/storage-domain/README.md +++ b/packages/storage/storage-domain/README.md @@ -85,7 +85,7 @@ The domain layer is a single implementation, not an abstracted seam: consumers d ### Design concept - **The spec object is the single source of truth.** `defineDomain` pins the spec's literal types and validates its fields at the owning package's module load, before any medium is touched. Record schemas are zod, so `z.infer` avoids duplicating consumer types; plugin `Config` stays schemastery. -- **Memory is authoritative; the medium is the durable projection.** Reads are synchronous from validated in-memory state. Every write queues on one per-domain write chain: backend durability first, then memory mutation, then `domain/changed` — a rejected backend write leaves memory untouched, so reads never diverge from the medium. +- **Memory is authoritative; the medium is the durable projection.** Reads are synchronous from validated in-memory state. Every write queues on one per-domain write chain: backend durability first, then memory mutation, then `domain/changed` — a rejected backend write leaves memory untouched, so reads never diverge from the medium; a backend that reports `unknown-state` quarantines the domain, whose reads and later writes then reject until it is closed. - **One write chain per domain.** `put`, `delete`, `deleteMany`, `update`, and `global.set` all queue on it; `update`'s transform runs at its chain slot, so concurrent updates never interleave. Records are plain immutable data — returned values are the stored objects themselves and must not be mutated in place. - **Writes emit after the commit point.** `domain/changed` is a notification, not a transaction participant: a throwing listener is contained with a logged warning rather than rejecting the already-durable write. diff --git a/packages/storage/storage-domain/README.zh.md b/packages/storage/storage-domain/README.zh.md index 44c89f1769..466db6ea24 100644 --- a/packages/storage/storage-domain/README.zh.md +++ b/packages/storage/storage-domain/README.zh.md @@ -85,7 +85,7 @@ domain.table('workspaces').update(id, (r) => ({ ...r, path: newPath })) ### 设计理念 - **spec 对象是唯一真源。** `defineDomain` 固定 spec 的字面类型,并在所属包的模块加载时、任何介质被触碰之前校验其字段。记录 schema 使用 zod,因此 `z.infer` 可避免重复定义消费方类型;插件 `Config` 仍由 schemastery 负责。 -- **内存具有最终决定权;介质是持久投影。** 读取同步取自经过校验的内存状态。每次写入都在每个领域一条的写入链上排队:先到达后端持久状态,再变更内存,然后发出 `domain/changed`——被拒绝的后端写入不会触碰内存,因此读取永远不会与介质分叉。 +- **内存具有最终决定权;介质是持久投影。** 读取同步取自经过校验的内存状态。每次写入都在每个领域一条的写入链上排队:先到达后端持久状态,再变更内存,然后发出 `domain/changed`——被拒绝的后端写入不会触碰内存,因此读取永远不会与介质分叉;若后端报告 `unknown-state`,领域会被隔离,其读取与后续写入都会拒绝,直到领域关闭。 - **每个领域一条写入链。** `put`、`delete`、`deleteMany`、`update` 与 `global.set` 都在其上排队;`update` 的变换在链上自己的槽位运行,因此并发更新绝不会交错。记录是普通不可变数据——返回值就是已存对象本身,绝不能原地修改。 - **写入在提交点之后发出。** `domain/changed` 是通知,不是事务参与者:监听器抛出异常时,系统会隔离该异常并记录警告,而不会让已经持久的写入被拒绝。 diff --git a/packages/storage/storage-domain/src/domain.ts b/packages/storage/storage-domain/src/domain.ts index 38ac7b7bfe..d61eedf8a9 100644 --- a/packages/storage/storage-domain/src/domain.ts +++ b/packages/storage/storage-domain/src/domain.ts @@ -10,7 +10,7 @@ */ import type { Context } from '@deepseek-ai/cordis' -import type { KvUnit } from '@deepseek-ai/dsh-storage' +import { StorageError, type KvUnit } from '@deepseek-ai/dsh-storage' import { DomainError } from './error.ts' import type { DomainSpec, DomainGlobalSpec, TableKeyOf, TableValueOf } from './spec.ts' import type { DomainChanged } from './events.ts' @@ -87,8 +87,9 @@ export interface KvTable { * it and no other write interleaves. Missing keys are ignored. The in-memory * records reach the committed state before any `domain/changed` listener * runs. A backend failure rejects with the batch unapplied, leaving every - * record in place; a backend that cannot restore its staged records rejects - * with `unknown-state` and keeps its unit unusable. + * record in place. A backend that cannot restore its staged records rejects + * with `unknown-state` and quarantines the domain: synchronous reads and + * every later write reject with that error, while `close()` still works. * @param keys - Record keys; duplicates are harmless. * @returns the number of keys that existed and were removed. */ @@ -164,6 +165,8 @@ export class DomainImpl { private chain: Promise = Promise.resolve() /** Set when close begins: new writes reject while already-queued writes drain. */ private disposing = false + /** Set when the unit reports `unknown-state`: reads and writes reject with it. */ + private failure?: StorageError /** Set when close finishes (chain drained, unit closed): reads reject from here on. */ private closed = false private disposal?: Promise @@ -279,12 +282,24 @@ export class DomainImpl { if (this.disposing) { return Promise.reject(new DomainError('closed', `domain '${this.name}' is closed`)) } - const result = this.chain.then(job) + const run = async (): Promise => { + // A poisoned unit makes in-memory state unverifiable: reject before the + // job runs, including jobs queued before the failure surfaced. + if (this.failure !== undefined) throw this.failure + try { + return await job() + } catch (error: unknown) { + if (error instanceof StorageError && error.code === 'unknown-state') this.failure = error + throw error + } + } + const result = this.chain.then(run) this.chain = result.then(noop, noop) return result } private assertReadable(): void { + if (this.failure !== undefined) throw this.failure if (this.closed) { throw new DomainError('closed', `domain '${this.name}' is closed`) } diff --git a/packages/storage/storage-domain/tests/domain.spec.ts b/packages/storage/storage-domain/tests/domain.spec.ts index 9c99cd0fab..b130e0cbcd 100644 --- a/packages/storage/storage-domain/tests/domain.spec.ts +++ b/packages/storage/storage-domain/tests/domain.spec.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' -import Storage, { storageBackendServiceKey } from '@deepseek-ai/dsh-storage' +import Storage, { StorageError, storageBackendServiceKey } from '@deepseek-ai/dsh-storage' +import type { KvUnit, StorageBackend } from '@deepseek-ai/dsh-storage' import { apply, defineDomain, descriptorOf, DomainFacility, domainTable } from '../src/index.ts' import type { Config } from '../src/index.ts' import type { DomainChanged } from '../src/events.ts' @@ -25,11 +26,11 @@ const bareSpec = defineDomain({ tables: { rows: domainTable(itemSchema) }, }) -/** Boot a context with the storage hub, one memory backend, and a facility over it. */ -async function harness(options?: { pool?: MemoryMediaPool; config?: Partial }) { +/** Boot a context with the storage hub, one KV backend, and a facility over it. */ +async function harness(options?: { pool?: MemoryMediaPool; config?: Partial; backend?: StorageBackend }) { const ctx = new Context() await ctx.plugin(Storage) - const backend = new MemoryStorageBackend(options?.pool) + const backend = options?.backend ?? new MemoryStorageBackend(options?.pool) ctx.storage.backend.register('memory', backend) const facility = new DomainFacility(ctx, { backend: 'memory', routes: {}, ...options?.config }) ctx.storage.mount('domain', facility) @@ -369,6 +370,44 @@ describe('durability failure', () => { expect(changes).toHaveLength(seen) }) + it('quarantines the domain after a batch reports unknown-state', async () => { + const inner = new MemoryStorageBackend() + const poisoned: StorageBackend = { + close: () => inner.close(), + kv: { + open: async (descriptor) => { + const kv = inner.kv + if (kv === undefined) throw new Error('memory backend is missing its kv facet') + const unit: KvUnit = await kv.open(descriptor) + return { + loadAll: () => unit.loadAll(), + putRecord: (table, key, value) => unit.putRecord(table, key, value), + deleteRecord: (table, key) => unit.deleteRecord(table, key), + deleteRecords: () => Promise.reject(new StorageError('unknown-state', 'unknown-state injected by the test')), + setGlobal: value => unit.setGlobal(value), + close: () => unit.close(), + } + }, + }, + } + const { facility } = await harness({ backend: poisoned }) + const domain = await facility.open(spec) + const table = domain.table('items') + await table.put('a', { label: 'x', count: 1 }) + + await expect(table.deleteMany(['a'])).rejects.toMatchObject({ code: 'unknown-state' }) + // Synchronous reads and later chain jobs see the quarantined domain. + expect(() => table.get('a')).toThrowError(/unknown-state/) + expect(() => [...table.keys()]).toThrowError(/unknown-state/) + expect(() => table.size).toThrowError(/unknown-state/) + await expect(table.deleteMany(['missing'])).rejects.toMatchObject({ code: 'unknown-state' }) + await expect(table.put('b', { label: 'y', count: 2 })).rejects.toMatchObject({ code: 'unknown-state' }) + await expect(domain.global.set({ theme: 'dark' })).rejects.toMatchObject({ code: 'unknown-state' }) + expect(() => domain.global.get()).toThrowError(/unknown-state/) + // Releasing the domain stays possible. + await expect(domain.close()).resolves.toBeUndefined() + }) + it('keeps serving initial when the first global set fails durability', async () => { const pool = new MemoryMediaPool() const { facility } = await harness({ pool }) diff --git a/packages/storage/storage-json/tests/per-record-batch.spec.ts b/packages/storage/storage-json/tests/per-record-batch.spec.ts index 884d992347..9372fc4d31 100644 --- a/packages/storage/storage-json/tests/per-record-batch.spec.ts +++ b/packages/storage/storage-json/tests/per-record-batch.spec.ts @@ -1,6 +1,6 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' import { JsonStorageBackend } from '../src/index.ts' @@ -50,25 +50,40 @@ it('waits for the whole batch before close releases the unit', async () => { await unit.putRecord('t', 'a', { v: 1 }) await unit.putRecord('t', 'b', { v: 2 }) - const entered = Promise.withResolvers() - const release = Promise.withResolvers() + const firstEntered = Promise.withResolvers() + const firstRelease = Promise.withResolvers() + const secondEntered = Promise.withResolvers() + const secondRelease = Promise.withResolvers() gates.renameOverride = async (_from, to) => { - if (to.endsWith('/b.json.deleting')) { - entered.resolve(undefined) - await release.promise + const name = basename(to) + if (name === 'a.json.deleting') { + firstEntered.resolve(undefined) + await firstRelease.promise + } + if (name === 'b.json.deleting') { + secondEntered.resolve(undefined) + await secondRelease.promise } return false } const batch = unit.deleteRecords('t', ['a', 'b']) - await entered.promise + await firstEntered.promise let closed = false const closing = unit.close().then(() => { closed = true }) - await new Promise(resolve => setTimeout(resolve, 25)) - // The batch still holds the unit: its rename is blocked inside the call. - expect(closed).toBe(false) - - release.resolve(undefined) + // Start close while the first document is still staged, then let the batch + // reach its second document. Per-rename tracking would let close() return + // here because the second rename was not registered before the snapshot. + firstRelease.resolve(undefined) + try { + await secondEntered.promise + await new Promise(resolve => setTimeout(resolve, 25)) + expect(closed).toBe(false) + } finally { + // Release the blocked batch even when the assertion fails, so teardown + // never waits on an abandoned rename. + secondRelease.resolve(undefined) + } await batch await closing expect(closed).toBe(true) @@ -88,8 +103,8 @@ it('rejects with unknown-state when a failed batch cannot restore its staged doc gates.renameOverride = async (from, to) => { // The second document cannot be staged, and the first cannot be restored. - if (to.endsWith('/b.json.deleting')) throw denied() - if (from.endsWith('/a.json.deleting')) throw denied() + if (basename(to) === 'b.json.deleting') throw denied() + if (basename(from) === 'a.json.deleting') throw denied() return false } From 3c7cfb8cf867d478bd05dc5aed4a1cccc2da5654 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 22 Oct 2026 18:31:12 +0800 Subject: [PATCH 23/85] fix(storage): propagate unknown-state through domains and clients --- docs/subsystems/schedule.i18n.yaml | 4 +- docs/subsystems/schedule.md | 4 +- docs/subsystems/schedule.zh.md | 4 +- docs/subsystems/storage.i18n.yaml | 4 +- docs/subsystems/storage.md | 2 +- docs/subsystems/storage.zh.md | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/schedule/schedule/README.i18n.yaml | 4 +- packages/schedule/schedule/README.md | 2 +- packages/schedule/schedule/README.zh.md | 2 +- packages/schedule/schedule/src/index.ts | 6 +++ .../schedule/tests/delete-inactive.spec.ts | 46 ++++++++++++++-- .../schedule/schedule/tests/plugin.spec.ts | 7 ++- packages/storage/storage-domain/src/domain.ts | 10 ++-- .../storage-domain/tests/domain.spec.ts | 49 ++++++++++++++++- .../storage/storage-json/README.i18n.yaml | 4 +- packages/storage/storage-json/README.md | 2 +- packages/storage/storage-json/README.zh.md | 2 +- .../tests/per-record-batch.spec.ts | 11 +++- .../storage/storage-sqlite/README.i18n.yaml | 4 +- packages/storage/storage-sqlite/README.md | 2 +- packages/storage/storage-sqlite/README.zh.md | 2 +- packages/storage/storage-sqlite/src/unit.ts | 26 ++++++++-- .../tests/sqlite-backend.spec.ts | 52 ++++++++++++++++++- packages/storage/storage/src/backend.ts | 18 ++++--- 25 files changed, 225 insertions(+), 46 deletions(-) diff --git a/docs/subsystems/schedule.i18n.yaml b/docs/subsystems/schedule.i18n.yaml index 85cdab2a17..84b5e5bdd0 100644 --- a/docs/subsystems/schedule.i18n.yaml +++ b/docs/subsystems/schedule.i18n.yaml @@ -27,8 +27,8 @@ en: 93798ad69f74ddd5 zh: 634126ad8accc356 /host-wide-schedule/web-catalog: - en: a4f9de4d4f88f018 - zh: 228a2e8fcc726f4c + en: 4d8f28a57062ce02 + zh: d8fe886d103b3d97 /host-wide-schedule/timing-edits: en: ce5d2095a3ad2e82 zh: 167b3715f8eaffc1 diff --git a/docs/subsystems/schedule.md b/docs/subsystems/schedule.md index 7e1459483d..e2c9af9cf1 100644 --- a/docs/subsystems/schedule.md +++ b/docs/subsystems/schedule.md @@ -345,7 +345,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. `schedule.deleteInactive()` removes every ended (inactive) task across Sessions, with the saved delivery records each row carries; on success it answers `{ deleted }` with the count it removed, and a failed batch rejects with nothing removed and nothing announced. The Automation tasks page's bulk cleanup uses it. An ended task never arms a timer, so the removal recomputes no target. The payload-free `schedule/changed` event invalidates client lists; reconnecting clients fetch current state again. +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. `schedule.deleteInactive()` removes every ended (inactive) task across Sessions, with the saved delivery records each row carries; on success it answers `{ deleted }` with the count it removed; a normal failed batch rejects with nothing removed, and an `unknown-state` failure may have removed rows, so the service announces `schedule/changed` before rejecting and clients re-read. The Automation tasks page's bulk cleanup uses it. An ended task never arms a timer, so the removal recomputes no target. The payload-free `schedule/changed` event invalidates client lists; reconnecting clients fetch current state again. The shipped Web composition 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. @@ -524,6 +524,8 @@ async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: Abor * slot, so a delivery ordered behind this call ends its task after the * enumeration rather than during it. The batch is one durable write: a failure * removes nothing and rejects, so the caller never receives a partial removal. + * A backend that reports `unknown-state` may have removed rows, so the service + * announces the change before rejecting and clients re-read the catalog. * @returns Count of removed rows; zero when the Host held no ended task. */ @Remote('deleteInactive') async deleteInactive(): Promise diff --git a/docs/subsystems/schedule.zh.md b/docs/subsystems/schedule.zh.md index d11c1eec8f..187a742b07 100644 --- a/docs/subsystems/schedule.zh.md +++ b/docs/subsystems/schedule.zh.md @@ -345,7 +345,7 @@ type ScheduleCatalogEntry = ScheduleRecord & { } ``` -Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。`schedule.deleteInactive()` 删除跨会话的全部未运行(已结束)任务,连同每行携带的已保存的发送记录;成功时以它移除的计数答复 `{ deleted }`;该批删除是一次持久写入,失败时不删除任何行,也不发通知,调用以拒绝结束。自动化任务页面的批量清理使用该方法。未运行任务从不会安排定时器,因此该删除不重算任何目标。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。 +Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。`schedule.deleteInactive()` 删除跨会话的全部未运行(已结束)任务,连同每行携带的已保存的发送记录;成功时以它移除的计数答复 `{ deleted }`;普通失败时不删除任何行,后端报告 `unknown-state` 时可能已移除部分行,服务会先发出 `schedule/changed` 再拒绝,客户端重新读取。自动化任务页面的批量清理使用该方法。未运行任务从不会安排定时器,因此该删除不重算任何目标。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。 随发行版交付的 Web 组合把 `ui-schedule` 与宿主能力一起挂载。[客户端包](../../packages/client/ui-schedule/README.zh.md) 负责目录、空状态与删除控件。页面单独筛选全部、活动和未运行任务,保留未运行任务详情及详情页签条内的原 Session 入口,并要求显式确认删除。“规则”和“任务运行记录”页签将任务设置与按需分页加载的已保存回执分开。回执与未运行状态均不确认模型执行。 @@ -524,6 +524,8 @@ async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: Abor * slot, so a delivery ordered behind this call ends its task after the * enumeration rather than during it. The batch is one durable write: a failure * removes nothing and rejects, so the caller never receives a partial removal. + * A backend that reports `unknown-state` may have removed rows, so the service + * announces the change before rejecting and clients re-read the catalog. * @returns Count of removed rows; zero when the Host held no ended task. */ @Remote('deleteInactive') async deleteInactive(): Promise diff --git a/docs/subsystems/storage.i18n.yaml b/docs/subsystems/storage.i18n.yaml index 5a59dee93e..4956c7bb90 100644 --- a/docs/subsystems/storage.i18n.yaml +++ b/docs/subsystems/storage.i18n.yaml @@ -9,8 +9,8 @@ en: b11e06e7c0e0fd13 zh: 4585f26ff416cb41 /storage/the-backend-contract: - en: b83300f10cc822f2 - zh: 61835181022a7ba6 + en: e9427832bd1dd9c7 + zh: 41176b1431ec965c /storage/declaring-a-domain: en: 775cce941500f01f zh: 78acbc67f48574cb diff --git a/docs/subsystems/storage.md b/docs/subsystems/storage.md index 1a86fc83ba..076281e174 100644 --- a/docs/subsystems/storage.md +++ b/docs/subsystems/storage.md @@ -44,7 +44,7 @@ interface StorageBackend { } ``` -A backend owns one medium (a file-tree root, a database file) and exposes optional operation groups; `kv` is the only shipped group. `KvFacet.open(descriptor)` opens one named unit — `KvUnitDescriptor` carries the name, current format version, optional compatible record versions, table names, and whether a global singleton slot exists — and returns a `KvUnit` with `loadAll`, `putRecord`, `deleteRecord`, `deleteRecords`, `setGlobal`, and `close`. Unit and table names must match `UNIT_NAME_RE` (safe as a file name and as a SQL identifier segment); record keys are arbitrary strings that never reach file paths. A unit does not serialize concurrent writes — ordering belongs to the caller — but each single call is atomic on the medium and durable once resolved. `deleteRecords` applies a whole batch in one call: it resolves once every listed record is durably absent, and a rejection leaves the batch unapplied; a backend that cannot restore staged records rejects with `unknown-state` and then rejects every later operation on that unit. A `single` medium stamped with a different version rejects `version-mismatch`; a `per-record` document stamped outside the accepted set reads as absent. A medium that cannot be parsed as the unit rejects `malformed-medium`. [`backend.ts`](../../packages/storage/storage/src/backend.ts) is the normative clause-by-clause contract, and the shared conformance suite in [`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) checks every clause against each backend. The [json backend](../../packages/storage/storage-json/README.md) republishes one whole human-readable file per unit atomically; the [sqlite backend](../../packages/storage/storage-sqlite/README.md) stores one document per row in one database for frequently updated data. +A backend owns one medium (a file-tree root, a database file) and exposes optional operation groups; `kv` is the only shipped group. `KvFacet.open(descriptor)` opens one named unit — `KvUnitDescriptor` carries the name, current format version, optional compatible record versions, table names, and whether a global singleton slot exists — and returns a `KvUnit` with `loadAll`, `putRecord`, `deleteRecord`, `deleteRecords`, `setGlobal`, and `close`. Unit and table names must match `UNIT_NAME_RE` (safe as a file name and as a SQL identifier segment); record keys are arbitrary strings that never reach file paths. A unit does not serialize concurrent writes — ordering belongs to the caller — but each single call is atomic on the medium and durable once resolved. `deleteRecords` applies a whole batch in one call: it resolves once every listed record is absent from the readable state and the backend accepted the batch, and a rejection normally leaves the batch unapplied; a backend that cannot establish or restore the batch's state rejects with `unknown-state`, after which every later operation on that unit (except `close()`) rejects with the same error. A `single`-layout failure after the atomic replacement (a directory-fsync error) can leave the new file on the medium while the caller sees a rejection; the unit keeps its memory consistent and the next publish rewrites the file. A `single` medium stamped with a different version rejects `version-mismatch`; a `per-record` document stamped outside the accepted set reads as absent. A medium that cannot be parsed as the unit rejects `malformed-medium`. [`backend.ts`](../../packages/storage/storage/src/backend.ts) is the normative clause-by-clause contract, and the shared conformance suite in [`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) checks every clause against each backend. The [json backend](../../packages/storage/storage-json/README.md) republishes one whole human-readable file per unit atomically; the [sqlite backend](../../packages/storage/storage-sqlite/README.md) stores one document per row in one database for frequently updated data. ## Declaring a domain diff --git a/docs/subsystems/storage.zh.md b/docs/subsystems/storage.zh.md index e0461f91ce..f8cdb43904 100644 --- a/docs/subsystems/storage.zh.md +++ b/docs/subsystems/storage.zh.md @@ -44,7 +44,7 @@ interface StorageBackend { } ``` -一个后端拥有一个介质(一棵文件树的根目录、一个数据库文件),并提供可选的操作组;`kv` 是唯一已交付的操作组。`KvFacet.open(descriptor)` 打开一个具名 unit——`KvUnitDescriptor` 携带名称、当前格式版本、可选的兼容记录版本、表名清单,以及是否存在全局单例 slot——并返回提供 `loadAll`、`putRecord`、`deleteRecord`、`deleteRecords`、`setGlobal` 和 `close` 的 `KvUnit`。unit 名与表名必须匹配 `UNIT_NAME_RE`(既可安全用作文件名,也可安全用作 SQL 标识符片段);记录键是任意字符串,绝不进入文件路径。unit 不对并发写入做串行化——顺序由调用方负责——但每次单独调用在介质上都是原子的,且 resolve 后即已持久。`deleteRecords` 以一次调用应用整批删除:所有列出的记录都持久缺失后才 resolve,拒绝时整批未应用;无法恢复暂存记录的批量删除会以 `unknown-state` 拒绝,并让该 unit 之后的所有操作都拒绝。`single` 介质上记录的版本不同时拒绝 `version-mismatch`;`per-record` 文档的版本在接受集合之外时读作不存在。无法按该 unit 解析的介质拒绝 `malformed-medium`。[`backend.ts`](../../packages/storage/storage/src/backend.ts) 是逐条款的规范性约定,[`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) 中的共享一致性套件会针对每个后端检查每项条款。[json 后端](../../packages/storage/storage-json/README.zh.md)以原子方式为每个 unit 整文件重新发布一份人类可读文件;[sqlite 后端](../../packages/storage/storage-sqlite/README.zh.md)在单个数据库中每行存储一份文档,用于频繁更新的数据。 +一个后端拥有一个介质(一棵文件树的根目录、一个数据库文件),并提供可选的操作组;`kv` 是唯一已交付的操作组。`KvFacet.open(descriptor)` 打开一个具名 unit——`KvUnitDescriptor` 携带名称、当前格式版本、可选的兼容记录版本、表名清单,以及是否存在全局单例 slot——并返回提供 `loadAll`、`putRecord`、`deleteRecord`、`deleteRecords`、`setGlobal` 和 `close` 的 `KvUnit`。unit 名与表名必须匹配 `UNIT_NAME_RE`(既可安全用作文件名,也可安全用作 SQL 标识符片段);记录键是任意字符串,绝不进入文件路径。unit 不对并发写入做串行化——顺序由调用方负责——但每次单独调用在介质上都是原子的,且 resolve 后即已持久。`deleteRecords` 以一次调用应用整批删除:所有列出的记录都从可读状态消失且后端接受该批后才 resolve,拒绝时整批未应用;无法建立或恢复批次状态的批量删除会以 `unknown-state` 拒绝,之后该 unit 的所有操作(`close()` 除外)都以同一错误拒绝。`single` 布局在原子替换之后失败(目录 fsync 错误)时,新文件可能已落在介质上而调用方看到拒绝;unit 的内存保持一致,下一次发布会重写该文件。`single` 介质上记录的版本不同时拒绝 `version-mismatch`;`per-record` 文档的版本在接受集合之外时读作不存在。无法按该 unit 解析的介质拒绝 `malformed-medium`。[`backend.ts`](../../packages/storage/storage/src/backend.ts) 是逐条款的规范性约定,[`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) 中的共享一致性套件会针对每个后端检查每项条款。[json 后端](../../packages/storage/storage-json/README.zh.md)以原子方式为每个 unit 整文件重新发布一份人类可读文件;[sqlite 后端](../../packages/storage/storage-sqlite/README.zh.md)在单个数据库中每行存储一份文档,用于频繁更新的数据。 ## 声明领域 diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 9691f2cb38..cddcf77b31 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2194,7 +2194,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: '@Remote(\'deleteInactive\') async deleteInactive(): Promise', - description: 'Delete every ended Host task, leaving active tasks armed.\n\nEnded tasks never schedule another delivery, so the removal changes no pending target and no timer is recomputed. Rows are enumerated and removed in one FIFO slot, so a delivery ordered behind this call ends its task after the enumeration rather than during it. The batch is one durable write: a failure removes nothing and rejects, so the caller never receives a partial removal.', + description: 'Delete every ended Host task, leaving active tasks armed.\n\nEnded tasks never schedule another delivery, so the removal changes no pending target and no timer is recomputed. Rows are enumerated and removed in one FIFO slot, so a delivery ordered behind this call ends its task after the enumeration rather than during it. The batch is one durable write: a failure removes nothing and rejects, so the caller never receives a partial removal. A backend that reports `unknown-state` may have removed rows, so the service announces the change before rejecting and clients re-read the catalog.', parameters: [], returns: 'Count of removed rows; zero when the Host held no ended task.', }, diff --git a/packages/schedule/schedule/README.i18n.yaml b/packages/schedule/schedule/README.i18n.yaml index 94e9eaafde..58982064cf 100644 --- a/packages/schedule/schedule/README.i18n.yaml +++ b/packages/schedule/schedule/README.i18n.yaml @@ -15,8 +15,8 @@ en: 23386452a66b2fbd zh: 4a8d91b2f313fead /deepseek-ai-dsh-schedule/use-this-package: - en: a4521f1f145b2dc7 - zh: 690d626671678f93 + en: e632295a37b7399d + zh: 3daae4e585e387c6 /deepseek-ai-dsh-schedule/understand-the-implementation: en: 02848e6fd6317b2a zh: 1807a423210f81ef diff --git a/packages/schedule/schedule/README.md b/packages/schedule/schedule/README.md index c76e76b6e8..e4fe6146cb 100644 --- a/packages/schedule/schedule/README.md +++ b/packages/schedule/schedule/README.md @@ -44,7 +44,7 @@ Weekly input adds `weekdays`, a non-empty set of ISO weekday numbers from Monday Cron input carries `expression` and `time_zone`. The expression is the standard five-field Vixie form `minute hour day-of-month month day-of-week`: minute 0-59, hour 0-23, day-of-month 1-31, month 1-12, and day-of-week 0-7 where both `0` and `7` mean Sunday. Every field accepts `*`, a single value, an `a-b` range, a `*/n` or `a-b/n` step with `n >= 1`, and a comma-separated list of those forms. `L`, `W`, `#`, `JAN`/`MON` names, `@daily`-style macros, six-field expressions, out-of-range values, inverted ranges, zero steps, and empty fields are rejected with `invalid_rule` and a message naming the offending field. Because the dialect has five fields, the smallest interval is one minute. Creation stores a canonical expression: repeated values collapse, adjacent values and ranges merge, a uniform step is written as `a-b/n` or, for a field that started with `*`, as a star-step (`*` for every value, otherwise the widest star-step walk plus any remaining values), a step of `1` is dropped, and Sunday is written as `0`. A field that did not start with `*` never becomes a star-step, so the day rule below survives storage. The durable decoder rejects a non-canonical stored expression, so the record always holds the canonical text. When either day-of-month or day-of-week is a star, a local date matches only when both fields match; when neither is a star, either field matching is enough. A field is a star when its text starts with `*`, independently of the values it matches, so a stepped star constrains alongside the other field instead of substituting for it. The first target is the first strictly future instant whose local date and time in that zone match, using the same gap skip and earlier-overlap rules as Daily. See [cron timing](../../../docs/subsystems/schedule.md#cron-wall-clock-input) for the full dialect and canonicalization rules. -A reminder is bound to the calling Agent's Session. The shared `schedule` Remote namespace exposes `catalog` for active and inactive Host tasks with their original Session ids, and `list`, `history`, `update`, and `delete` with an explicit Session id. It also exposes `deleteInactive`, which removes every inactive task across Sessions and answers how many rows it removed; an inactive task never arms a timer, so that removal recomputes no target, and the batch is one durable write, so a failure removes nothing and rejects instead of reporting a count. Remote `list` and model `schedule_list` return only active tasks. Catalog entries retain only the latest receipt in `lastDelivery`; catalog and list responses omit saved delivery history. None of these operations activates an Agent or reads Session logs. Explicit deletion stops future delivery, leaves the original Session and already queued messages intact, and removes the stored task row together with its saved delivery records: the task leaves `list` and `catalog`, never schedules again, and `history` answers `schedule_not_found` for the same `(sessionId, id)`. +A reminder is bound to the calling Agent's Session. The shared `schedule` Remote namespace exposes `catalog` for active and inactive Host tasks with their original Session ids, and `list`, `history`, `update`, and `delete` with an explicit Session id. It also exposes `deleteInactive`, which removes every inactive task across Sessions and answers how many rows it removed; an inactive task never arms a timer, so that removal recomputes no target, and the batch is one durable write, so a normal failure removes nothing and rejects instead of reporting a count; a backend `unknown-state` failure may have removed rows, so the service announces `schedule/changed` before rejecting and clients re-read. Remote `list` and model `schedule_list` return only active tasks. Catalog entries retain only the latest receipt in `lastDelivery`; catalog and list responses omit saved delivery history. None of these operations activates an Agent or reads Session logs. Explicit deletion stops future delivery, leaves the original Session and already queued messages intact, and removes the stored task row together with its saved delivery records: the task leaves `list` and `catalog`, never schedules again, and `history` answers `schedule_not_found` for the same `(sessionId, id)`. Archiving a Session with active reminders is refused until they stop, and choosing to stop them deletes every active reminder; unarchiving does not bring them back. diff --git a/packages/schedule/schedule/README.zh.md b/packages/schedule/schedule/README.zh.md index e1a650358b..9c3d0b79e1 100644 --- a/packages/schedule/schedule/README.zh.md +++ b/packages/schedule/schedule/README.zh.md @@ -44,7 +44,7 @@ Schedule 将一次性、固定周期、按每日、按每周以及 cron 本地 cron 输入携带 `expression` 和 `time_zone`。表达式是标准五字段 Vixie 形式 `minute hour day-of-month month day-of-week`:minute 0-59、hour 0-23、day-of-month 1-31、month 1-12、day-of-week 0-7,其中 `0` 和 `7` 都表示周日。每个字段接受 `*`、单个值、`a-b` 范围、`n >= 1` 的 `*/n` 与 `a-b/n` 步长,以及由这些形式组成的逗号分隔列表。`L`、`W`、`#`、`JAN`/`MON` 名称、`@daily` 风格宏、六字段表达式、超出范围的值、反向范围、零步长和空字段都会以 `invalid_rule` 拒绝,且错误信息会指出违规字段。由于该方言只有五个字段,最小间隔为一分钟。创建时存储规范化表达式:重复值合并,相邻的值与范围合并,均匀步长写为 `a-b/n`,而以 `*` 开头的字段写为星号步长(匹配全部取值时写 `*`,否则写最宽的星号步长并追加其余取值),步长 `1` 被移除,周日统一写为 `0`。不以 `*` 开头的字段绝不会变成星号步长,因此下文的日规则在存储后保持不变。持久化解码器拒绝非规范化的已存储表达式,因此记录始终保存规范化文本。day-of-month 或 day-of-week 中任一为星号字段时,本地日期需两个字段都匹配;两者都不是星号字段时,匹配任一字段即可。字段文本以 `*` 开头即为星号字段,与它匹配的取值集合无关,因此步长星号字段与其他字段同时约束,而不是替代它。首个目标是该时区内本地日期与时间匹配的第一个严格未来时点,使用与 Daily 相同的缺口跳过与重叠取较早时点规则。完整方言与规范化规则见 [cron 时间语义](../../../docs/subsystems/schedule.zh.md#cron-wall-clock-input)。 -提醒绑定到调用 Agent 的会话。共享的 `schedule` Remote namespace 提供 `catalog`,返回活动和未运行的宿主任务及其原始会话 id,并提供带明确会话 id 的 `list`、`history`、`update` 和 `delete`。它还提供 `deleteInactive`,删除所有会话中全部未运行(界面显示为「已结束」)任务并返回移除的行数;未运行任务从不会安排定时器,因此该删除不重算任何目标,且该批删除是一次持久写入,失败时不删除任何行,调用以拒绝结束而不返回计数。Remote `list` 和模型 `schedule_list` 仅返回活动任务。目录条目的 `lastDelivery` 仅保留最近一次回执;目录和列表响应均不包含已保存的发送历史。这些操作均不激活 Agent 或读取会话日志。显式删除会阻止后续投递,保留原会话和已经入队的消息,并移除已存储的任务行及其已保存的发送记录:任务从 `list` 和 `catalog` 中消失、不再调度,`history` 对相同的 `(sessionId, id)` 返回 `schedule_not_found`。 +提醒绑定到调用 Agent 的会话。共享的 `schedule` Remote namespace 提供 `catalog`,返回活动和未运行的宿主任务及其原始会话 id,并提供带明确会话 id 的 `list`、`history`、`update` 和 `delete`。它还提供 `deleteInactive`,删除所有会话中全部未运行(界面显示为「已结束」)任务并返回移除的行数;未运行任务从不会安排定时器,因此该删除不重算任何目标,且该批删除是一次持久写入,普通失败时不删除任何行,调用以拒绝结束而不返回计数;后端报告 `unknown-state` 时可能已移除部分行,服务会先发出 `schedule/changed` 再拒绝,客户端重新读取。Remote `list` 和模型 `schedule_list` 仅返回活动任务。目录条目的 `lastDelivery` 仅保留最近一次回执;目录和列表响应均不包含已保存的发送历史。这些操作均不激活 Agent 或读取会话日志。显式删除会阻止后续投递,保留原会话和已经入队的消息,并移除已存储的任务行及其已保存的发送记录:任务从 `list` 和 `catalog` 中消失、不再调度,`history` 对相同的 `(sessionId, id)` 返回 `schedule_not_found`。 归档仍有活动提醒的会话会被拒绝,直到这些提醒停止;选择停止它们会删除全部活动提醒,取消归档不会把它们带回来。 diff --git a/packages/schedule/schedule/src/index.ts b/packages/schedule/schedule/src/index.ts index b5dd3b8617..82015ff24f 100644 --- a/packages/schedule/schedule/src/index.ts +++ b/packages/schedule/schedule/src/index.ts @@ -374,6 +374,8 @@ export class ScheduleService extends TypertRemoteService { * slot, so a delivery ordered behind this call ends its task after the * enumeration rather than during it. The batch is one durable write: a failure * removes nothing and rejects, so the caller never receives a partial removal. + * A backend that reports `unknown-state` may have removed rows, so the service + * announces the change before rejecting and clients re-read the catalog. * @returns Count of removed rows; zero when the Host held no ended task. */ @Remote('deleteInactive') @@ -504,6 +506,10 @@ export class ScheduleService extends TypertRemoteService { try { removed = await (await this.getDomain()).table('tasks').deleteMany(ids) } catch (error: unknown) { + // A rejection never establishes which rows landed, and the backend can + // report `unknown-state` after removing some of them. Announce the change + // so observers re-read instead of trusting their stale catalog. + this.emitChanged() throw error instanceof Error ? error : new Error(`${label}: ${String(error)}`) } // Rows enumerated in this slot are present, so a non-empty batch always removed rows. diff --git a/packages/schedule/schedule/tests/delete-inactive.spec.ts b/packages/schedule/schedule/tests/delete-inactive.spec.ts index 707a20a9b7..4e15abee18 100644 --- a/packages/schedule/schedule/tests/delete-inactive.spec.ts +++ b/packages/schedule/schedule/tests/delete-inactive.spec.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { createAfterScheduleRecord, createEveryScheduleRecord, ScheduleId } from '../src/domain.ts' import type { DomainFacility } from '@deepseek-ai/dsh-storage-domain' import { scheduleDomain, type ScheduleTask } from '../src/storage.ts' +import { StorageError } from '@deepseek-ai/dsh-storage' import { MemoryMediaPool } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' import { gatedScheduleBackend, harness } from './harness.ts' @@ -195,13 +196,52 @@ it('removes nothing and reports the batch failure', async () => { expect(failure).toBeInstanceOf(Error) expect(String(failure)).toContain('injected batch failure') - // One write carried both rows; the rejection left both stored and announced nothing. + // One write carried both rows; the rejection may have landed some of them, so + // observers are told to re-read even though both rows remain in this catalog. expect(batches).toEqual([['ended-a', 'ended-b']]) - expect(changed).not.toHaveBeenCalled() + expect(changed).toHaveBeenCalledTimes(1) expect((await service.catalog()).map(entry => entry.title).sort()) .toEqual(['Partial first', 'Partial second']) }) +it('quarantines the Host domain when the backend reports unknown-state', async () => { + const pool = new MemoryMediaPool() + let batches = 0 + const backend = gatedScheduleBackend(pool, { + deleteRecords: async (table) => { + if (table !== 'tasks') return + batches += 1 + throw new StorageError('unknown-state', 'injected unknown-state') + }, + }) + const test = await harness({ + pool, + backend, + beforeService: async (_ctx, facility) => { + const domain = await facility.open(scheduleDomain) + await domain.table('tasks').put(ScheduleId('ended-a'), endedRow(SessionId('quarantine-a'), 'ended-a', 'Quarantine first')) + await domain.table('tasks').put(ScheduleId('ended-b'), endedRow(SessionId('quarantine-b'), 'ended-b', 'Quarantine second')) + await domain.close() + }, + }) + tests.push(test) + const { ctx, service } = test + const changed = vi.fn() + ctx.on('schedule/changed', changed) + + const failure = await service.deleteInactive().then(() => undefined, (error: unknown) => error) + expect(failure).toMatchObject({ name: 'StorageError', code: 'unknown-state' }) + expect(batches).toBe(1) + expect(changed).toHaveBeenCalledTimes(1) + // The quarantined domain rejects reads instead of serving stale rows. + await expect(service.catalog()).rejects.toMatchObject({ code: 'unknown-state' }) + // Later batches are rejected before reaching the backend again. + await expect(service.deleteInactive()).rejects.toMatchObject({ code: 'unknown-state' }) + expect(batches).toBe(1) + // The durable rows are still there; only the in-process domain is quarantined. + expect(pool.media.get('schedule')?.tables.get('tasks')?.size).toBe(2) +}) + it('reports a rejection reason that carries no Error as a labeled Error', async () => { const pool = new MemoryMediaPool() let batches = 0 @@ -233,7 +273,7 @@ it('reports a rejection reason that carries no Error as a labeled Error', async expect(failure).toBeInstanceOf(Error) expect(String(failure)).toContain('schedule ended-task removal failed') expect(batches).toBe(1) - expect(changed).not.toHaveBeenCalled() + expect(changed).toHaveBeenCalledTimes(1) expect((await service.catalog()).map(entry => entry.title).sort()) .toEqual(['Unreportable first', 'Unreportable second']) }) diff --git a/packages/schedule/schedule/tests/plugin.spec.ts b/packages/schedule/schedule/tests/plugin.spec.ts index a507b5181f..ad2195c712 100644 --- a/packages/schedule/schedule/tests/plugin.spec.ts +++ b/packages/schedule/schedule/tests/plugin.spec.ts @@ -195,7 +195,9 @@ describe('shared Schedule management', () => { .then(() => undefined, (error: unknown) => error) expect(failure).toBeInstanceOf(AggregateError) expect(batches).toBe(1) - expect(changed).not.toHaveBeenCalled() + // The batch may have landed some rows under unknown-state, so the service + // announces the change before the rejection reaches the caller. + expect(changed).toHaveBeenCalledTimes(1) expect((await service.catalog()).map(entry => entry.title).sort()).toEqual(['First', 'Second']) }) @@ -222,8 +224,9 @@ describe('shared Schedule management', () => { // Nothing landed, and a reason that carries no Error must not read as success // with the reminder still armed. expect(failure).toBeInstanceOf(AggregateError) + expect((failure as AggregateError).errors.map(String).some(text => text.includes('schedule stop failed'))).toBe(true) expect(batches).toBe(1) - expect(changed).not.toHaveBeenCalled() + expect(changed).toHaveBeenCalledTimes(1) expect((await service.catalog()).map(entry => entry.title)).toEqual(['First']) }) diff --git a/packages/storage/storage-domain/src/domain.ts b/packages/storage/storage-domain/src/domain.ts index d61eedf8a9..4f240d4c99 100644 --- a/packages/storage/storage-domain/src/domain.ts +++ b/packages/storage/storage-domain/src/domain.ts @@ -4,8 +4,9 @@ * from memory; every write queues on the chain, awaits backend durability * FIRST, then mutates memory, then emits `domain/changed` — a rejected * backend write leaves memory untouched (no divergence between reads and the - * medium), and events carry values that equal the in-memory state at - * emission, in write order. + * medium) unless the backend reports `unknown-state`, which quarantines the + * domain: reads and later writes reject until close. Events carry values that + * equal the in-memory state at emission, in write order. * @module @deepseek-ai/dsh-storage-domain/src/domain */ @@ -140,7 +141,7 @@ interface TableHost { readonly unit: KvUnit /** Queue one job on the domain's single write chain. */ enqueue(job: () => Promise): Promise - /** Throw `closed` once the domain has fully closed (reads stay valid while draining). */ + /** Throw `closed` after teardown, or the recorded `unknown-state` while quarantined. */ assertReadable(): void /** Emit `domain/changed` for one durably landed write. */ emitChanged(change: DomainChanged): void @@ -299,10 +300,11 @@ export class DomainImpl { } private assertReadable(): void { - if (this.failure !== undefined) throw this.failure + // Close wins: the KvUnit contract reports `closed` for calls after teardown. if (this.closed) { throw new DomainError('closed', `domain '${this.name}' is closed`) } + if (this.failure !== undefined) throw this.failure } } diff --git a/packages/storage/storage-domain/tests/domain.spec.ts b/packages/storage/storage-domain/tests/domain.spec.ts index b130e0cbcd..40edc985e4 100644 --- a/packages/storage/storage-domain/tests/domain.spec.ts +++ b/packages/storage/storage-domain/tests/domain.spec.ts @@ -309,6 +309,43 @@ describe('KvTable writes', () => { expect(observed).toEqual([[], []]) }) + it('calls the backend batch primitive exactly once per deleteMany', async () => { + const inner = new MemoryStorageBackend() + let batches = 0 + let singles = 0 + const counting: StorageBackend = { + close: () => inner.close(), + kv: { + open: async (descriptor) => { + const kv = inner.kv + if (kv === undefined) throw new Error('memory backend is missing its kv facet') + const unit = await kv.open(descriptor) + return { + loadAll: () => unit.loadAll(), + putRecord: (table, key, value) => unit.putRecord(table, key, value), + deleteRecord: async (table, key) => { + singles += 1 + return unit.deleteRecord(table, key) + }, + deleteRecords: async (table, keys) => { + batches += 1 + return unit.deleteRecords(table, keys) + }, + setGlobal: value => unit.setGlobal(value), + close: () => unit.close(), + } + }, + }, + } + const { facility } = await harness({ backend: counting }) + const table = (await facility.open(spec)).table('items') + await table.put('a', { label: 'x', count: 1 }) + await table.put('b', { label: 'y', count: 2 }) + await expect(table.deleteMany(['a', 'b', 'missing'])).resolves.toBe(2) + expect(batches).toBe(1) + expect(singles).toBe(0) + }) + it('emits domain/changed per durable write, in order, with tombstones and global marker', async () => { const { facility, changes } = await harness() const domain = await facility.open(spec) @@ -368,6 +405,10 @@ describe('durability failure', () => { ]) expect(pool.media.get('demo')!.tables.get('items')!.get('b')).toEqual({ label: 'y', count: 2 }) expect(changes).toHaveLength(seen) + + // The write chain keeps serving after a rejected batch. + await table.put('c', { label: 'z', count: 3 }) + expect(table.get('c')).toEqual({ label: 'z', count: 3 }) }) it('quarantines the domain after a batch reports unknown-state', async () => { @@ -395,13 +436,17 @@ describe('durability failure', () => { const table = domain.table('items') await table.put('a', { label: 'x', count: 1 }) - await expect(table.deleteMany(['a'])).rejects.toMatchObject({ code: 'unknown-state' }) + // A later job queued before the failure surfaces is rejected too. + const failing = table.deleteMany(['a']) + const queued = table.put('b', { label: 'y', count: 2 }) + await expect(failing).rejects.toMatchObject({ code: 'unknown-state' }) + await expect(queued).rejects.toMatchObject({ code: 'unknown-state' }) // Synchronous reads and later chain jobs see the quarantined domain. expect(() => table.get('a')).toThrowError(/unknown-state/) expect(() => [...table.keys()]).toThrowError(/unknown-state/) expect(() => table.size).toThrowError(/unknown-state/) await expect(table.deleteMany(['missing'])).rejects.toMatchObject({ code: 'unknown-state' }) - await expect(table.put('b', { label: 'y', count: 2 })).rejects.toMatchObject({ code: 'unknown-state' }) + await expect(table.put('c', { label: 'z', count: 3 })).rejects.toMatchObject({ code: 'unknown-state' }) await expect(domain.global.set({ theme: 'dark' })).rejects.toMatchObject({ code: 'unknown-state' }) expect(() => domain.global.get()).toThrowError(/unknown-state/) // Releasing the domain stays possible. diff --git a/packages/storage/storage-json/README.i18n.yaml b/packages/storage/storage-json/README.i18n.yaml index cfff00f5b3..96ca9927da 100644 --- a/packages/storage/storage-json/README.i18n.yaml +++ b/packages/storage/storage-json/README.i18n.yaml @@ -30,8 +30,8 @@ en: b7d222309b3e5438 zh: 807c7314ee341d94 /deepseek-ai-dsh-storage-json/understand-the-implementation/design-concept: - en: d4f84e0e5d2b3770 - zh: 87eca52475ea2fe6 + en: 7baa51f75f15a237 + zh: 1415a2b73940e41d /deepseek-ai-dsh-storage-json/understand-the-implementation/file-formats: en: b3de0a697db4f916 zh: 56b26f7823392bd5 diff --git a/packages/storage/storage-json/README.md b/packages/storage/storage-json/README.md index 55f50d666e..7362b334be 100644 --- a/packages/storage/storage-json/README.md +++ b/packages/storage/storage-json/README.md @@ -70,7 +70,7 @@ The two layouts share atomic publication but assign state ownership differently. ### Design concept - **`single` keeps memory authoritative.** Each write changes the in-memory unit, serializes its complete state, and atomically replaces `.json`. A failed publish restores the prior in-memory value. A batch delete is one such publish: the whole batch is applied or restored together. -- **`per-record` keeps the directory authoritative.** Each put or delete changes one `/
/.json` document, and `loadAll()` rereads the tree. A batch delete renames the batch's documents out of the readable set, restores them if a rename fails, and unlinks the staged copies afterwards. If that restoration fails, the unit rejects with `unknown-state` and every later operation on it rejects. Each document stamps the unit version and carries one record value. +- **`per-record` keeps the directory authoritative.** Each put or delete changes one `/
/.json` document, and `loadAll()` rereads the tree. A batch delete renames the batch's documents out of the readable set, restores them if a rename fails, and unlinks the staged copies afterwards. If that restoration fails, the unit rejects with `unknown-state` and every later operation on it rejects. The batch's renames and unlinks are not followed by a directory fsync, so crash durability follows the filesystem's entry-update guarantees: a crash can resurrect a staged document or leave `.deleting` residue. Each document stamps the unit version and carries one record value. - **Publication is durable per call.** A write uses a temporary file, fsync, atomic `rename()` replacement, and a parent-directory fsync on POSIX. The domain layer's write chain supplies ordering across calls. ### File formats diff --git a/packages/storage/storage-json/README.zh.md b/packages/storage/storage-json/README.zh.md index b59d7dbdea..b3d5f1c96f 100644 --- a/packages/storage/storage-json/README.zh.md +++ b/packages/storage/storage-json/README.zh.md @@ -70,7 +70,7 @@ kind: "package-reference" ### 设计理念 - **`single` 以内存为权威状态。** 每次写入都会更改内存单元、序列化其完整状态,并以原子方式替换 `.json`。发布失败会恢复先前的内存值。批量删除就是这样一次发布:整批一起应用或一起恢复。 -- **`per-record` 以目录为权威状态。** 每次 put 或 delete 都会更改一个 `/
/.json` 文档,`loadAll()` 则重新读取目录树。每份文档都带有单元版本戳与一条记录值。批量删除先把整批文档改名移出可读集合,任一次改名失败就恢复,之后删除暂存副本;恢复本身失败时该 unit 以 `unknown-state` 拒绝,之后对它的所有操作都会拒绝。 +- **`per-record` 以目录为权威状态。** 每次 put 或 delete 都会更改一个 `/
/.json` 文档,`loadAll()` 则重新读取目录树。每份文档都带有单元版本戳与一条记录值。批量删除先把整批文档改名移出可读集合,任一次改名失败就恢复,之后删除暂存副本;恢复本身失败时该 unit 以 `unknown-state` 拒绝,之后对它的所有操作都会拒绝。批量删除的改名与删除后不做目录 fsync,因此崩溃后的持久性取决于文件系统的目录项更新保证:崩溃可能让暂存文档重新出现,或留下 `.deleting` 残留。 - **每次调用都持久发布。** 写入过程使用临时文件、fsync、原子 `rename()` 替换,并在 POSIX 上 fsync 父目录。领域层写入链负责安排跨调用的顺序。 ### 文件格式 diff --git a/packages/storage/storage-json/tests/per-record-batch.spec.ts b/packages/storage/storage-json/tests/per-record-batch.spec.ts index 9372fc4d31..319898470b 100644 --- a/packages/storage/storage-json/tests/per-record-batch.spec.ts +++ b/packages/storage/storage-json/tests/per-record-batch.spec.ts @@ -24,7 +24,7 @@ vi.mock('node:fs/promises', async (importOriginal) => { } }) -const DESCRIPTOR = { name: 'batch', version: 1, layout: 'per-record' as const, tables: ['t'], hasGlobal: false } +const DESCRIPTOR = { name: 'batch', version: 1, layout: 'per-record' as const, tables: ['t'], hasGlobal: true } const roots: string[] = [] async function freshRoot(): Promise { @@ -49,6 +49,8 @@ it('waits for the whole batch before close releases the unit', async () => { const unit = await backend.kv.open(DESCRIPTOR) await unit.putRecord('t', 'a', { v: 1 }) await unit.putRecord('t', 'b', { v: 2 }) + // An empty batch is a settled no-op that never touches the medium. + await expect(unit.deleteRecords('t', [])).resolves.toBeUndefined() const firstEntered = Promise.withResolvers() const firstRelease = Promise.withResolvers() @@ -116,7 +118,14 @@ it('rejects with unknown-state when a failed batch cannot restore its staged doc await expect(unit.loadAll()).rejects.toMatchObject({ code: 'unknown-state' }) await expect(unit.putRecord('t', 'c', { v: 3 })).rejects.toMatchObject({ code: 'unknown-state' }) await expect(unit.deleteRecord('t', 'a')).rejects.toMatchObject({ code: 'unknown-state' }) + await expect(unit.setGlobal({ g: 1 })).rejects.toMatchObject({ code: 'unknown-state' }) + await expect(unit.backupRecord('t', 'b')).rejects.toMatchObject({ code: 'unknown-state' }) // Releasing the handle stays possible. await unit.close() + // A reopened unit reads the partial tree: the staged document is unreadable + // residue and the second record is still present. + const reopened = await backend.kv.open(DESCRIPTOR) + expect(await reopened.loadAll()).toEqual({ tables: { t: { b: { v: 2 } } }, global: null }) + await reopened.close() await backend.close() }) diff --git a/packages/storage/storage-sqlite/README.i18n.yaml b/packages/storage/storage-sqlite/README.i18n.yaml index 0795b556f9..f7f6e9e2c8 100644 --- a/packages/storage/storage-sqlite/README.i18n.yaml +++ b/packages/storage/storage-sqlite/README.i18n.yaml @@ -30,8 +30,8 @@ en: 6867b52cbb94e572 zh: 187d5d61980c8fe7 /deepseek-ai-dsh-storage-sqlite/understand-the-implementation/design-concept: - en: 54ee8d9d7746e82d - zh: 510c8bca88d2495c + en: af6e4b3df6d321cd + zh: 9922901704a6780f /deepseek-ai-dsh-storage-sqlite/understand-the-implementation/open-sequence: en: 50d85e8d8e8b5bc1 zh: 2a7067bd7fb47b35 diff --git a/packages/storage/storage-sqlite/README.md b/packages/storage/storage-sqlite/README.md index 940a464490..b58eec9813 100644 --- a/packages/storage/storage-sqlite/README.md +++ b/packages/storage/storage-sqlite/README.md @@ -69,7 +69,7 @@ The backend is a document-per-row layout over one `node:sqlite` connection, desi ### Design concept - **Document per row.** Each unit table becomes a physical STRICT table `u__
(key TEXT PRIMARY KEY, value TEXT)` whose `value` column holds the record's JSON text; the global singleton lives in a shared `unit_globals` table. One key update touches exactly one row — the reason to route a high-churn domain here. -- **Single-statement atomicity.** Every write primitive is one prepared statement, so SQLite's per-statement atomicity satisfies the KV contract without explicit transactions; write ordering stays the caller's responsibility (the domain layer's write chain). +- **Statement atomicity.** Every single-record write is one prepared statement; a batch delete runs its deletes in one explicit transaction, so the batch commits or rolls back together. A batch that cannot open or roll back its transaction rejects with `unknown-state`, after which every later operation on the unit rejects; write ordering stays the caller's responsibility (the domain layer's write chain). - **Names validated before DDL.** Unit and table names must match `UNIT_NAME_RE` before they reach DDL, so no external input is ever interpolated into SQL identifiers. - **Versions fail loud.** The physical layout version lives in `PRAGMA user_version` (fresh databases stamp it last); unit format versions live in the `units` table. Any other stamped value rejects — no migrations. diff --git a/packages/storage/storage-sqlite/README.zh.md b/packages/storage/storage-sqlite/README.zh.md index 89743bbd9e..40262a66c5 100644 --- a/packages/storage/storage-sqlite/README.zh.md +++ b/packages/storage/storage-sqlite/README.zh.md @@ -69,7 +69,7 @@ kind: "package-reference" ### 设计理念 - **每行一份文档。** 每个单元表都变成一张物理 STRICT 表 `u__
(key TEXT PRIMARY KEY, value TEXT)`,其 `value` 列保存记录的 JSON 文本;全局单例存放在共享的 `unit_globals` 表中。一个键的更新恰好触碰一行——这就是把高频变更领域路由到这里的原因。 -- **语句原子性。** 每个单记录写入都是一条预处理语句;批量删除在一个显式事务中执行其删除,因此整批一起提交或回滚。写入顺序仍由调用方负责(领域层的写入链)。 +- **语句原子性。** 每个单记录写入都是一条预处理语句;批量删除在一个显式事务中执行其删除,因此整批一起提交或回滚。无法开启或回滚事务的批量删除以 `unknown-state` 拒绝,之后对该 unit 的所有操作都会拒绝;写入顺序仍由调用方负责(领域层的写入链)。 - **名称在 DDL 之前校验。** 单元名与表名在进入 DDL 之前必须匹配 `UNIT_NAME_RE`,因此任何外部输入都不会被插值进 SQL 标识符。 - **版本明确报错。** 物理布局版本存放在 `PRAGMA user_version`(全新数据库最后盖戳);单元格式版本存放在 `units` 表中。任何其他已标记值都会被拒绝——不做迁移。 diff --git a/packages/storage/storage-sqlite/src/unit.ts b/packages/storage/storage-sqlite/src/unit.ts index f43988cdf4..05e4771192 100644 --- a/packages/storage/storage-sqlite/src/unit.ts +++ b/packages/storage/storage-sqlite/src/unit.ts @@ -30,6 +30,8 @@ export class SqliteKvUnit implements KvUnit { private readonly globalUpsert: StatementSync | undefined private readonly globalSelect: StatementSync | undefined private closed = false + /** Set when a batch transaction could not be opened or rolled back. */ + private unusable: StorageError | undefined /** * @param db - Open database handle owned by the backend (never closed here). @@ -112,7 +114,18 @@ export class SqliteKvUnit implements KvUnit { deleteRecords(table: string, keys: readonly string[]): Promise { return this.settle(() => { const remove = this.statementsFor(table).remove - this.db.exec('BEGIN IMMEDIATE') + try { + this.db.exec('BEGIN IMMEDIATE') + } catch (error: unknown) { + // An unopened transaction can only mean the connection is not in the + // state this unit expects; later statements would run in an unknown one. + this.unusable = new StorageError( + 'unknown-state', + `kv unit '${this.descriptor.name}' could not open a batch transaction; its readable state is unknown`, + { cause: error }, + ) + throw this.unusable + } try { for (const key of new Set(keys)) remove.run(key) this.db.exec('COMMIT') @@ -120,10 +133,12 @@ export class SqliteKvUnit implements KvUnit { try { this.db.exec('ROLLBACK') } catch (rollbackError: unknown) { - // A failed rollback leaves the connection's state to its own next - // statement; both failures are reported, neither is discarded. - /* v8 ignore next -- ROLLBACK fails only on an unusable connection; the next statement reports it. */ - throw new AggregateError([error, rollbackError], `kv unit '${this.descriptor.name}' batch delete failed and could not roll back`) + this.unusable = new StorageError( + 'unknown-state', + `kv unit '${this.descriptor.name}' batch delete failed and could not roll back; its readable state is unknown`, + { cause: new AggregateError([error, rollbackError], 'batch delete failed and rollback failed') }, + ) + throw this.unusable } throw error } @@ -166,6 +181,7 @@ export class SqliteKvUnit implements KvUnit { if (this.closed) { throw new StorageError('closed', `kv unit '${this.descriptor.name}' is closed`) } + if (this.unusable !== undefined) throw this.unusable } private statementsFor(table: string): TableStatements { diff --git a/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts b/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts index 795d832d05..9078fb7dfe 100644 --- a/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts +++ b/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -67,6 +67,56 @@ describe('sqlite backend specifics', () => { await backend.close() }) + it('reports unknown-state when a failed batch cannot roll back', async () => { + const path = await freshDbPath() + const backend = backendAt(path) + const unit = await backend.kv.open(DESCRIPTOR) + await unit.putRecord('records', 'a', { n: 1 }) + await unit.putRecord('records', 'b', { n: 2 }) + const db = new DatabaseSync(path) + db.exec('CREATE TRIGGER block_b BEFORE DELETE ON "u_specimen_records"' + + " WHEN old.key = 'b' BEGIN SELECT RAISE(ABORT, 'blocked'); END") + const original = DatabaseSync.prototype.exec + const execSpy = vi.spyOn(DatabaseSync.prototype, 'exec').mockImplementation(function (this: DatabaseSync, sql: string) { + if (sql === 'ROLLBACK') throw new Error('rollback denied') + return original.call(this, sql) + }) + try { + await expect(unit.deleteRecords('records', ['a', 'b'])).rejects.toMatchObject({ code: 'unknown-state' }) + // Later statements would run inside the still-open transaction. + await expect(unit.putRecord('records', 'c', { n: 3 })).rejects.toMatchObject({ code: 'unknown-state' }) + await expect(unit.loadAll()).rejects.toMatchObject({ code: 'unknown-state' }) + } finally { + execSpy.mockRestore() + db.close() + } + // The denied rollback leaves the unit's connection in a transaction; the + // backend close is what releases it, and this test's file is discarded by + // afterEach. + await expect(unit.close()).resolves.toBeUndefined() + await backend.close() + }) + + it('reports unknown-state when a batch cannot open its transaction', async () => { + const path = await freshDbPath() + const backend = backendAt(path) + const unit = await backend.kv.open(DESCRIPTOR) + await unit.putRecord('records', 'a', { n: 1 }) + const original = DatabaseSync.prototype.exec + const execSpy = vi.spyOn(DatabaseSync.prototype, 'exec').mockImplementation(function (this: DatabaseSync, sql: string) { + if (sql.startsWith('BEGIN')) throw new Error('begin denied') + return original.call(this, sql) + }) + try { + await expect(unit.deleteRecords('records', ['a'])).rejects.toMatchObject({ code: 'unknown-state' }) + await expect(unit.deleteRecord('records', 'a')).rejects.toMatchObject({ code: 'unknown-state' }) + } finally { + execSpy.mockRestore() + } + await expect(unit.close()).resolves.toBeUndefined() + await backend.close() + }) + it('materializes STRICT record tables and stamps the schema version', async () => { const path = await freshDbPath() const backend = backendAt(path) diff --git a/packages/storage/storage/src/backend.ts b/packages/storage/storage/src/backend.ts index 7dc1d1a9ba..1dfe6a3c03 100644 --- a/packages/storage/storage/src/backend.ts +++ b/packages/storage/storage/src/backend.ts @@ -112,13 +112,17 @@ export interface KvUnit { /** * Delete several records durably as one batch. Idempotent: missing keys are * no-ops. Resolves once every listed record is absent from the readable - * state and durable; a rejection leaves the batch unapplied, so no listed - * record was removed. A backend that stages records and then cannot restore - * them rejects with `unknown-state` instead: the readable state is then - * unknown and every later operation on that unit rejects with the same - * error, while {@link close} still releases the unit. A backend whose medium - * can apply a whole batch in one operation (the `single` document, a row - * store) must do that instead of repeating {@link deleteRecord} per key. + * state and the backend accepted the batch; a rejection leaves the batch + * unapplied, so no listed record was removed. A backend that cannot + * establish or restore the batch's state rejects with `unknown-state` + * instead: the readable state is then unknown, every later operation on + * that unit rejects with the same error, and only {@link close} still + * releases it. On the `single` layout a failure after the atomic replacement + * (a directory-fsync error) can leave the new file on the medium while the + * caller sees a rejection; the unit keeps its memory consistent and the next + * publish rewrites the file. A backend whose medium can apply a whole batch + * in one operation (the `single` document, a row store) must do that instead + * of repeating {@link deleteRecord} per key. * @param table - Declared table name. * @param keys - Record keys; duplicates are harmless. In the `per-record` * layout every key becomes a path segment and must match `[a-zA-Z0-9_-]+` From 35fce127c00e129981de8452028107065458f7b6 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 22 Oct 2026 18:33:38 +0800 Subject: [PATCH 24/85] test(storage): guard the optional backupRecord probe --- packages/storage/storage-json/tests/per-record-batch.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/storage/storage-json/tests/per-record-batch.spec.ts b/packages/storage/storage-json/tests/per-record-batch.spec.ts index 319898470b..b6c8746a97 100644 --- a/packages/storage/storage-json/tests/per-record-batch.spec.ts +++ b/packages/storage/storage-json/tests/per-record-batch.spec.ts @@ -119,7 +119,9 @@ it('rejects with unknown-state when a failed batch cannot restore its staged doc await expect(unit.putRecord('t', 'c', { v: 3 })).rejects.toMatchObject({ code: 'unknown-state' }) await expect(unit.deleteRecord('t', 'a')).rejects.toMatchObject({ code: 'unknown-state' }) await expect(unit.setGlobal({ g: 1 })).rejects.toMatchObject({ code: 'unknown-state' }) - await expect(unit.backupRecord('t', 'b')).rejects.toMatchObject({ code: 'unknown-state' }) + const backup = unit.backupRecord?.bind(unit) + if (backup === undefined) throw new Error('per-record unit must expose backupRecord') + await expect(backup('t', 'b')).rejects.toMatchObject({ code: 'unknown-state' }) // Releasing the handle stays possible. await unit.close() // A reopened unit reads the partial tree: the staged document is unreadable From ed7d73098f034fd6062655f85206b7ddb66a059c Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 22 Oct 2026 18:38:09 +0800 Subject: [PATCH 25/85] docs(storage): align unknown-state contracts and failure wording --- docs/subsystems/schedule.i18n.yaml | 4 +-- docs/subsystems/schedule.md | 12 +++++---- docs/subsystems/schedule.zh.md | 12 +++++---- docs/subsystems/storage.i18n.yaml | 4 +-- docs/subsystems/storage.md | 2 +- docs/subsystems/storage.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 +- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/schedule/schedule/README.i18n.yaml | 4 +-- packages/schedule/schedule/README.md | 2 +- packages/schedule/schedule/README.zh.md | 2 +- packages/schedule/schedule/src/index.ts | 18 ++++++++----- .../schedule/tests/delete-inactive.spec.ts | 2 +- packages/storage/storage-domain/src/domain.ts | 9 ++++--- .../storage/storage-json/README.i18n.yaml | 12 ++++----- packages/storage/storage-json/README.md | 5 ++-- packages/storage/storage-json/README.zh.md | 5 ++-- .../storage/storage-sqlite/README.i18n.yaml | 8 +++--- packages/storage/storage-sqlite/README.md | 4 +-- packages/storage/storage-sqlite/README.zh.md | 4 +-- packages/storage/storage/README.i18n.yaml | 4 +-- packages/storage/storage/README.md | 2 +- packages/storage/storage/README.zh.md | 2 +- packages/storage/storage/src/backend.ts | 27 ++++++++++--------- 26 files changed, 84 insertions(+), 72 deletions(-) diff --git a/docs/subsystems/schedule.i18n.yaml b/docs/subsystems/schedule.i18n.yaml index 84b5e5bdd0..63354f0fd0 100644 --- a/docs/subsystems/schedule.i18n.yaml +++ b/docs/subsystems/schedule.i18n.yaml @@ -27,8 +27,8 @@ en: 93798ad69f74ddd5 zh: 634126ad8accc356 /host-wide-schedule/web-catalog: - en: 4d8f28a57062ce02 - zh: d8fe886d103b3d97 + en: 3377a013bf8cbfb7 + zh: c5666dc3f28b087f /host-wide-schedule/timing-edits: en: ce5d2095a3ad2e82 zh: 167b3715f8eaffc1 diff --git a/docs/subsystems/schedule.md b/docs/subsystems/schedule.md index e2c9af9cf1..13f1752a31 100644 --- a/docs/subsystems/schedule.md +++ b/docs/subsystems/schedule.md @@ -345,7 +345,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. `schedule.deleteInactive()` removes every ended (inactive) task across Sessions, with the saved delivery records each row carries; on success it answers `{ deleted }` with the count it removed; a normal failed batch rejects with nothing removed, and an `unknown-state` failure may have removed rows, so the service announces `schedule/changed` before rejecting and clients re-read. The Automation tasks page's bulk cleanup uses it. An ended task never arms a timer, so the removal recomputes no target. The payload-free `schedule/changed` event invalidates client lists; reconnecting clients fetch current state again. +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. `schedule.deleteInactive()` removes every ended (inactive) task across Sessions, with the saved delivery records each row carries; on success it answers `{ deleted }` with the count it removed; a normal failed batch rejects with nothing removed; an `unknown-state` failure may have removed rows and quarantines the Host schedule domain, so later schedule operations reject until it is reopened. Any rejection announces `schedule/changed`, and clients then re-read. The Automation tasks page's bulk cleanup uses it. An ended task never arms a timer, so the removal recomputes no target. The payload-free `schedule/changed` event invalidates client lists; reconnecting clients fetch current state again. The shipped Web composition 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. @@ -522,10 +522,12 @@ async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: Abor * Ended tasks never schedule another delivery, so the removal changes no pending * target and no timer is recomputed. Rows are enumerated and removed in one FIFO * slot, so a delivery ordered behind this call ends its task after the - * enumeration rather than during it. The batch is one durable write: a failure - * removes nothing and rejects, so the caller never receives a partial removal. - * A backend that reports `unknown-state` may have removed rows, so the service - * announces the change before rejecting and clients re-read the catalog. + * enumeration rather than during it. The batch is one domain write: a normal + * failure rejects with every listed row still stored, while a backend + * `unknown-state` failure may have removed rows and quarantines the Host + * domain, so later schedule operations reject until the domain is reopened. + * Every rejection announces `schedule/changed` because it does not establish + * which rows landed, and clients then re-read the catalog. * @returns Count of removed rows; zero when the Host held no ended task. */ @Remote('deleteInactive') async deleteInactive(): Promise diff --git a/docs/subsystems/schedule.zh.md b/docs/subsystems/schedule.zh.md index 187a742b07..320ae8e743 100644 --- a/docs/subsystems/schedule.zh.md +++ b/docs/subsystems/schedule.zh.md @@ -345,7 +345,7 @@ type ScheduleCatalogEntry = ScheduleRecord & { } ``` -Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。`schedule.deleteInactive()` 删除跨会话的全部未运行(已结束)任务,连同每行携带的已保存的发送记录;成功时以它移除的计数答复 `{ deleted }`;普通失败时不删除任何行,后端报告 `unknown-state` 时可能已移除部分行,服务会先发出 `schedule/changed` 再拒绝,客户端重新读取。自动化任务页面的批量清理使用该方法。未运行任务从不会安排定时器,因此该删除不重算任何目标。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。 +Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。`schedule.deleteInactive()` 删除跨会话的全部未运行(已结束)任务,连同每行携带的已保存的发送记录;成功时以它移除的计数答复 `{ deleted }`;普通失败时不删除任何行;后端报告 `unknown-state` 时可能已移除部分行,并会隔离 Host 的 schedule 领域,之后该领域的操作都会拒绝,直到重新打开。任何拒绝都会先发出 `schedule/changed`,客户端重新读取。自动化任务页面的批量清理使用该方法。未运行任务从不会安排定时器,因此该删除不重算任何目标。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。 随发行版交付的 Web 组合把 `ui-schedule` 与宿主能力一起挂载。[客户端包](../../packages/client/ui-schedule/README.zh.md) 负责目录、空状态与删除控件。页面单独筛选全部、活动和未运行任务,保留未运行任务详情及详情页签条内的原 Session 入口,并要求显式确认删除。“规则”和“任务运行记录”页签将任务设置与按需分页加载的已保存回执分开。回执与未运行状态均不确认模型执行。 @@ -522,10 +522,12 @@ async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: Abor * Ended tasks never schedule another delivery, so the removal changes no pending * target and no timer is recomputed. Rows are enumerated and removed in one FIFO * slot, so a delivery ordered behind this call ends its task after the - * enumeration rather than during it. The batch is one durable write: a failure - * removes nothing and rejects, so the caller never receives a partial removal. - * A backend that reports `unknown-state` may have removed rows, so the service - * announces the change before rejecting and clients re-read the catalog. + * enumeration rather than during it. The batch is one domain write: a normal + * failure rejects with every listed row still stored, while a backend + * `unknown-state` failure may have removed rows and quarantines the Host + * domain, so later schedule operations reject until the domain is reopened. + * Every rejection announces `schedule/changed` because it does not establish + * which rows landed, and clients then re-read the catalog. * @returns Count of removed rows; zero when the Host held no ended task. */ @Remote('deleteInactive') async deleteInactive(): Promise diff --git a/docs/subsystems/storage.i18n.yaml b/docs/subsystems/storage.i18n.yaml index 4956c7bb90..5f4126783c 100644 --- a/docs/subsystems/storage.i18n.yaml +++ b/docs/subsystems/storage.i18n.yaml @@ -9,8 +9,8 @@ en: b11e06e7c0e0fd13 zh: 4585f26ff416cb41 /storage/the-backend-contract: - en: e9427832bd1dd9c7 - zh: 41176b1431ec965c + en: 225e25b9b4b08865 + zh: 53bd3554da610607 /storage/declaring-a-domain: en: 775cce941500f01f zh: 78acbc67f48574cb diff --git a/docs/subsystems/storage.md b/docs/subsystems/storage.md index 076281e174..8dddb5818e 100644 --- a/docs/subsystems/storage.md +++ b/docs/subsystems/storage.md @@ -44,7 +44,7 @@ interface StorageBackend { } ``` -A backend owns one medium (a file-tree root, a database file) and exposes optional operation groups; `kv` is the only shipped group. `KvFacet.open(descriptor)` opens one named unit — `KvUnitDescriptor` carries the name, current format version, optional compatible record versions, table names, and whether a global singleton slot exists — and returns a `KvUnit` with `loadAll`, `putRecord`, `deleteRecord`, `deleteRecords`, `setGlobal`, and `close`. Unit and table names must match `UNIT_NAME_RE` (safe as a file name and as a SQL identifier segment); record keys are arbitrary strings that never reach file paths. A unit does not serialize concurrent writes — ordering belongs to the caller — but each single call is atomic on the medium and durable once resolved. `deleteRecords` applies a whole batch in one call: it resolves once every listed record is absent from the readable state and the backend accepted the batch, and a rejection normally leaves the batch unapplied; a backend that cannot establish or restore the batch's state rejects with `unknown-state`, after which every later operation on that unit (except `close()`) rejects with the same error. A `single`-layout failure after the atomic replacement (a directory-fsync error) can leave the new file on the medium while the caller sees a rejection; the unit keeps its memory consistent and the next publish rewrites the file. A `single` medium stamped with a different version rejects `version-mismatch`; a `per-record` document stamped outside the accepted set reads as absent. A medium that cannot be parsed as the unit rejects `malformed-medium`. [`backend.ts`](../../packages/storage/storage/src/backend.ts) is the normative clause-by-clause contract, and the shared conformance suite in [`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) checks every clause against each backend. The [json backend](../../packages/storage/storage-json/README.md) republishes one whole human-readable file per unit atomically; the [sqlite backend](../../packages/storage/storage-sqlite/README.md) stores one document per row in one database for frequently updated data. +A backend owns one medium (a file-tree root, a database file) and exposes optional operation groups; `kv` is the only shipped group. `KvFacet.open(descriptor)` opens one named unit — `KvUnitDescriptor` carries the name, current format version, optional compatible record versions, table names, and whether a global singleton slot exists — and returns a `KvUnit` with `loadAll`, `putRecord`, `deleteRecord`, `deleteRecords`, `setGlobal`, and `close`. Unit and table names must match `UNIT_NAME_RE` (safe as a file name and as a SQL identifier segment); record keys are opaque strings in the `single` layout and match `[a-zA-Z0-9_-]+` path segments in the `per-record` layout. A unit does not serialize concurrent writes — ordering belongs to the caller — but each single call is atomic on the medium; durability holds for single-record writes and whole-file publishes, while a `per-record` batch deletion relies on the filesystem persisting directory-entry updates. `deleteRecords` applies a whole batch in one call: it resolves once every listed record is absent from the readable state and the backend accepted the batch, and a rejection normally leaves the batch unapplied; a backend that cannot establish or restore the batch's state rejects with `unknown-state`, after which every later operation on that unit rejects with the same error until `close()` releases it (calls after close reject `closed`). A `single`-layout failure after the atomic replacement (a directory-fsync error) can leave the new file on the medium while the caller sees a rejection; the unit keeps its memory consistent and the next publish rewrites the file. A `single` medium stamped with a different version rejects `version-mismatch`; a `per-record` document stamped outside the accepted set reads as absent. A medium that cannot be parsed as the unit rejects `malformed-medium`. [`backend.ts`](../../packages/storage/storage/src/backend.ts) is the normative clause-by-clause contract, and the shared conformance suite in [`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) checks the shared clauses against each backend. The [json backend](../../packages/storage/storage-json/README.md) republishes one whole human-readable file per unit atomically; the [sqlite backend](../../packages/storage/storage-sqlite/README.md) stores one document per row in one database for frequently updated data. ## Declaring a domain diff --git a/docs/subsystems/storage.zh.md b/docs/subsystems/storage.zh.md index f8cdb43904..7411fa632b 100644 --- a/docs/subsystems/storage.zh.md +++ b/docs/subsystems/storage.zh.md @@ -44,7 +44,7 @@ interface StorageBackend { } ``` -一个后端拥有一个介质(一棵文件树的根目录、一个数据库文件),并提供可选的操作组;`kv` 是唯一已交付的操作组。`KvFacet.open(descriptor)` 打开一个具名 unit——`KvUnitDescriptor` 携带名称、当前格式版本、可选的兼容记录版本、表名清单,以及是否存在全局单例 slot——并返回提供 `loadAll`、`putRecord`、`deleteRecord`、`deleteRecords`、`setGlobal` 和 `close` 的 `KvUnit`。unit 名与表名必须匹配 `UNIT_NAME_RE`(既可安全用作文件名,也可安全用作 SQL 标识符片段);记录键是任意字符串,绝不进入文件路径。unit 不对并发写入做串行化——顺序由调用方负责——但每次单独调用在介质上都是原子的,且 resolve 后即已持久。`deleteRecords` 以一次调用应用整批删除:所有列出的记录都从可读状态消失且后端接受该批后才 resolve,拒绝时整批未应用;无法建立或恢复批次状态的批量删除会以 `unknown-state` 拒绝,之后该 unit 的所有操作(`close()` 除外)都以同一错误拒绝。`single` 布局在原子替换之后失败(目录 fsync 错误)时,新文件可能已落在介质上而调用方看到拒绝;unit 的内存保持一致,下一次发布会重写该文件。`single` 介质上记录的版本不同时拒绝 `version-mismatch`;`per-record` 文档的版本在接受集合之外时读作不存在。无法按该 unit 解析的介质拒绝 `malformed-medium`。[`backend.ts`](../../packages/storage/storage/src/backend.ts) 是逐条款的规范性约定,[`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) 中的共享一致性套件会针对每个后端检查每项条款。[json 后端](../../packages/storage/storage-json/README.zh.md)以原子方式为每个 unit 整文件重新发布一份人类可读文件;[sqlite 后端](../../packages/storage/storage-sqlite/README.zh.md)在单个数据库中每行存储一份文档,用于频繁更新的数据。 +一个后端拥有一个介质(一棵文件树的根目录、一个数据库文件),并提供可选的操作组;`kv` 是唯一已交付的操作组。`KvFacet.open(descriptor)` 打开一个具名 unit——`KvUnitDescriptor` 携带名称、当前格式版本、可选的兼容记录版本、表名清单,以及是否存在全局单例 slot——并返回提供 `loadAll`、`putRecord`、`deleteRecord`、`deleteRecords`、`setGlobal` 和 `close` 的 `KvUnit`。unit 名与表名必须匹配 `UNIT_NAME_RE`(既可安全用作文件名,也可安全用作 SQL 标识符片段);记录键在 `single` 布局中是不透明字符串,在 `per-record` 布局中则是匹配 `[a-zA-Z0-9_-]+` 的路径片段。unit 不对并发写入做串行化——顺序由调用方负责——但每次单独调用在介质上都是原子的;单记录写入与整文件发布在 resolve 后即已持久,`per-record` 批量删除则依赖文件系统对目录项更新的持久化。`deleteRecords` 以一次调用应用整批删除:所有列出的记录都从可读状态消失且后端接受该批后才 resolve,拒绝时通常整批未应用;无法建立或恢复批次状态的批量删除会以 `unknown-state` 拒绝,之后该 unit 的所有操作都以同一错误拒绝,直到 `close()` 释放它(关闭后的调用以 `closed` 拒绝)。`single` 布局在原子替换之后失败(目录 fsync 错误)时,新文件可能已落在介质上而调用方看到拒绝;unit 的内存保持一致,下一次发布会重写该文件。`single` 介质上记录的版本不同时拒绝 `version-mismatch`;`per-record` 文档的版本在接受集合之外时读作不存在。无法按该 unit 解析的介质拒绝 `malformed-medium`。[`backend.ts`](../../packages/storage/storage/src/backend.ts) 是逐条款的规范性约定,[`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) 中的共享一致性套件会针对每个后端检查共享条款。[json 后端](../../packages/storage/storage-json/README.zh.md)以原子方式为每个 unit 整文件重新发布一份人类可读文件;[sqlite 后端](../../packages/storage/storage-sqlite/README.zh.md)在单个数据库中每行存储一份文档,用于频繁更新的数据。 ## 声明领域 diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index 4d8d777289..c887fb21cf 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: e4c481487bb8abc2 - zh: 2f14b2ea13172c80 + en: 0dfe5bbe9826aaa6 + zh: 9f480110c7652f1b /deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source: en: 47cd16c142e82385 zh: 181849a3c7a167fd diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index c02c19d11d..1e6463cbf3 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -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 inactive tasks.` with the count the Host answered, `No inactive tasks to clear.` when the Host reports that it removed none, and `Could not clear every inactive task.` after a failure that leaves the remaining inactive 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 inactive tasks.` with the count the Host answered, `No inactive tasks to clear.` when the Host reports that it removed none, and `Could not clear every inactive task.` after a failure; a normal failure leaves the remaining inactive rows for a retry, while a backend `unknown-state` failure may have removed some rows and quarantines the Host's schedule domain until it is reopened. 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. diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index edba0baca6..76fad49770 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -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 答复未移除任何行时显示「没有可清理的已结束任务」;失败时显示「无法清理全部已结束任务」;普通失败会保留尚未移除的已结束行以便重试,后端报告 `unknown-state` 时可能已移除部分行,并会隔离 Host 的 schedule 领域,直到重新打开。批量清理只关闭自己的确认对话框。确认删除会关闭确认所在的面板或页签,每条提示按自身序号区分,后一条会重新开始横幅而不是并入前一条。查询失败保留最近已知记录,并显示独立的重试操作;不会呈现为查询成功的空结果。一次性任务在持久收件箱投递后仍以已结束状态保存。显式删除前,通过全部或已结束筛选仍可查看详情与原会话 id。最近一次投递显示其发生时间,不显示消息 id 和投递确认时间;已结束状态和该回执均不表示模型执行完成。 已打开的会话只有在确实有可打开的内容时,才在页头右侧功能位中、紧邻更多操作菜单左侧渲染一个仅图标时钟按钮:首次读取前不渲染任何内容,读取结果为没有活动提醒时页头不显示该按钮,刷新期间保留上一次已知的提醒,读取失败则保留按钮及其重试。该规则避免时钟在首次结果前后出现又消失。其无障碍名称在读取就绪时给出提醒数量,否则显示通用提醒标签。当该会话只有一个活动提醒时,按钮直接在右侧栏打开该任务的详情。其余数量都会打开弹层:先列出逾期提醒,再按目标时间列出未来提醒,显示已存储的标题、频率、浏览器本地目标时间、相对时间及删除按钮;标题本身是一个按钮,点击后在右侧栏打开该任务的详情。任务页签在标签上显示当前任务已存储的标题,并提供与自动化任务页面相同的规则、任务运行记录、名称、指令和运行时间修改、确认删除和原会话入口;它不会激活会话。每日频率文本保留保存的本地时间和时区;每个下一目标时点都显示为设备时区的钟点和括号内的相对时长;规则自身的时区只显示在该频率文本中。Escape 关闭弹层并将焦点返回按钮;点击外部将其关闭。删除最后一条提醒会移除按钮;在此之前,已经打开的弹层保持可展开状态。 diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index cddcf77b31..7ce4c2c3ac 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2194,7 +2194,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: '@Remote(\'deleteInactive\') async deleteInactive(): Promise', - description: 'Delete every ended Host task, leaving active tasks armed.\n\nEnded tasks never schedule another delivery, so the removal changes no pending target and no timer is recomputed. Rows are enumerated and removed in one FIFO slot, so a delivery ordered behind this call ends its task after the enumeration rather than during it. The batch is one durable write: a failure removes nothing and rejects, so the caller never receives a partial removal. A backend that reports `unknown-state` may have removed rows, so the service announces the change before rejecting and clients re-read the catalog.', + description: 'Delete every ended Host task, leaving active tasks armed.\n\nEnded tasks never schedule another delivery, so the removal changes no pending target and no timer is recomputed. Rows are enumerated and removed in one FIFO slot, so a delivery ordered behind this call ends its task after the enumeration rather than during it. The batch is one domain write: a normal failure rejects with every listed row still stored, while a backend `unknown-state` failure may have removed rows and quarantines the Host domain, so later schedule operations reject until the domain is reopened. Every rejection announces `schedule/changed` because it does not establish which rows landed, and clients then re-read the catalog.', parameters: [], returns: 'Count of removed rows; zero when the Host held no ended task.', }, diff --git a/packages/schedule/schedule/README.i18n.yaml b/packages/schedule/schedule/README.i18n.yaml index 58982064cf..9b096b0a45 100644 --- a/packages/schedule/schedule/README.i18n.yaml +++ b/packages/schedule/schedule/README.i18n.yaml @@ -15,8 +15,8 @@ en: 23386452a66b2fbd zh: 4a8d91b2f313fead /deepseek-ai-dsh-schedule/use-this-package: - en: e632295a37b7399d - zh: 3daae4e585e387c6 + en: 8de4642089a0ca4f + zh: 5d5fe5ab846a476f /deepseek-ai-dsh-schedule/understand-the-implementation: en: 02848e6fd6317b2a zh: 1807a423210f81ef diff --git a/packages/schedule/schedule/README.md b/packages/schedule/schedule/README.md index e4fe6146cb..fc76fe3513 100644 --- a/packages/schedule/schedule/README.md +++ b/packages/schedule/schedule/README.md @@ -44,7 +44,7 @@ Weekly input adds `weekdays`, a non-empty set of ISO weekday numbers from Monday Cron input carries `expression` and `time_zone`. The expression is the standard five-field Vixie form `minute hour day-of-month month day-of-week`: minute 0-59, hour 0-23, day-of-month 1-31, month 1-12, and day-of-week 0-7 where both `0` and `7` mean Sunday. Every field accepts `*`, a single value, an `a-b` range, a `*/n` or `a-b/n` step with `n >= 1`, and a comma-separated list of those forms. `L`, `W`, `#`, `JAN`/`MON` names, `@daily`-style macros, six-field expressions, out-of-range values, inverted ranges, zero steps, and empty fields are rejected with `invalid_rule` and a message naming the offending field. Because the dialect has five fields, the smallest interval is one minute. Creation stores a canonical expression: repeated values collapse, adjacent values and ranges merge, a uniform step is written as `a-b/n` or, for a field that started with `*`, as a star-step (`*` for every value, otherwise the widest star-step walk plus any remaining values), a step of `1` is dropped, and Sunday is written as `0`. A field that did not start with `*` never becomes a star-step, so the day rule below survives storage. The durable decoder rejects a non-canonical stored expression, so the record always holds the canonical text. When either day-of-month or day-of-week is a star, a local date matches only when both fields match; when neither is a star, either field matching is enough. A field is a star when its text starts with `*`, independently of the values it matches, so a stepped star constrains alongside the other field instead of substituting for it. The first target is the first strictly future instant whose local date and time in that zone match, using the same gap skip and earlier-overlap rules as Daily. See [cron timing](../../../docs/subsystems/schedule.md#cron-wall-clock-input) for the full dialect and canonicalization rules. -A reminder is bound to the calling Agent's Session. The shared `schedule` Remote namespace exposes `catalog` for active and inactive Host tasks with their original Session ids, and `list`, `history`, `update`, and `delete` with an explicit Session id. It also exposes `deleteInactive`, which removes every inactive task across Sessions and answers how many rows it removed; an inactive task never arms a timer, so that removal recomputes no target, and the batch is one durable write, so a normal failure removes nothing and rejects instead of reporting a count; a backend `unknown-state` failure may have removed rows, so the service announces `schedule/changed` before rejecting and clients re-read. Remote `list` and model `schedule_list` return only active tasks. Catalog entries retain only the latest receipt in `lastDelivery`; catalog and list responses omit saved delivery history. None of these operations activates an Agent or reads Session logs. Explicit deletion stops future delivery, leaves the original Session and already queued messages intact, and removes the stored task row together with its saved delivery records: the task leaves `list` and `catalog`, never schedules again, and `history` answers `schedule_not_found` for the same `(sessionId, id)`. +A reminder is bound to the calling Agent's Session. The shared `schedule` Remote namespace exposes `catalog` for active and inactive Host tasks with their original Session ids, and `list`, `history`, `update`, and `delete` with an explicit Session id. It also exposes `deleteInactive`, which removes every inactive task across Sessions and answers how many rows it removed; an inactive task never arms a timer, so that removal recomputes no target, and the batch is one durable write, so a normal failure removes nothing and rejects instead of reporting a count; a backend `unknown-state` failure may have removed rows and quarantines the Host schedule domain, so later schedule operations reject until it is reopened, and any rejection announces `schedule/changed` so clients re-read. Remote `list` and model `schedule_list` return only active tasks. Catalog entries retain only the latest receipt in `lastDelivery`; catalog and list responses omit saved delivery history. None of these operations activates an Agent or reads Session logs. Explicit deletion stops future delivery, leaves the original Session and already queued messages intact, and removes the stored task row together with its saved delivery records: the task leaves `list` and `catalog`, never schedules again, and `history` answers `schedule_not_found` for the same `(sessionId, id)`. Archiving a Session with active reminders is refused until they stop, and choosing to stop them deletes every active reminder; unarchiving does not bring them back. diff --git a/packages/schedule/schedule/README.zh.md b/packages/schedule/schedule/README.zh.md index 9c3d0b79e1..cc8866fdbd 100644 --- a/packages/schedule/schedule/README.zh.md +++ b/packages/schedule/schedule/README.zh.md @@ -44,7 +44,7 @@ Schedule 将一次性、固定周期、按每日、按每周以及 cron 本地 cron 输入携带 `expression` 和 `time_zone`。表达式是标准五字段 Vixie 形式 `minute hour day-of-month month day-of-week`:minute 0-59、hour 0-23、day-of-month 1-31、month 1-12、day-of-week 0-7,其中 `0` 和 `7` 都表示周日。每个字段接受 `*`、单个值、`a-b` 范围、`n >= 1` 的 `*/n` 与 `a-b/n` 步长,以及由这些形式组成的逗号分隔列表。`L`、`W`、`#`、`JAN`/`MON` 名称、`@daily` 风格宏、六字段表达式、超出范围的值、反向范围、零步长和空字段都会以 `invalid_rule` 拒绝,且错误信息会指出违规字段。由于该方言只有五个字段,最小间隔为一分钟。创建时存储规范化表达式:重复值合并,相邻的值与范围合并,均匀步长写为 `a-b/n`,而以 `*` 开头的字段写为星号步长(匹配全部取值时写 `*`,否则写最宽的星号步长并追加其余取值),步长 `1` 被移除,周日统一写为 `0`。不以 `*` 开头的字段绝不会变成星号步长,因此下文的日规则在存储后保持不变。持久化解码器拒绝非规范化的已存储表达式,因此记录始终保存规范化文本。day-of-month 或 day-of-week 中任一为星号字段时,本地日期需两个字段都匹配;两者都不是星号字段时,匹配任一字段即可。字段文本以 `*` 开头即为星号字段,与它匹配的取值集合无关,因此步长星号字段与其他字段同时约束,而不是替代它。首个目标是该时区内本地日期与时间匹配的第一个严格未来时点,使用与 Daily 相同的缺口跳过与重叠取较早时点规则。完整方言与规范化规则见 [cron 时间语义](../../../docs/subsystems/schedule.zh.md#cron-wall-clock-input)。 -提醒绑定到调用 Agent 的会话。共享的 `schedule` Remote namespace 提供 `catalog`,返回活动和未运行的宿主任务及其原始会话 id,并提供带明确会话 id 的 `list`、`history`、`update` 和 `delete`。它还提供 `deleteInactive`,删除所有会话中全部未运行(界面显示为「已结束」)任务并返回移除的行数;未运行任务从不会安排定时器,因此该删除不重算任何目标,且该批删除是一次持久写入,普通失败时不删除任何行,调用以拒绝结束而不返回计数;后端报告 `unknown-state` 时可能已移除部分行,服务会先发出 `schedule/changed` 再拒绝,客户端重新读取。Remote `list` 和模型 `schedule_list` 仅返回活动任务。目录条目的 `lastDelivery` 仅保留最近一次回执;目录和列表响应均不包含已保存的发送历史。这些操作均不激活 Agent 或读取会话日志。显式删除会阻止后续投递,保留原会话和已经入队的消息,并移除已存储的任务行及其已保存的发送记录:任务从 `list` 和 `catalog` 中消失、不再调度,`history` 对相同的 `(sessionId, id)` 返回 `schedule_not_found`。 +提醒绑定到调用 Agent 的会话。共享的 `schedule` Remote namespace 提供 `catalog`,返回活动和未运行的宿主任务及其原始会话 id,并提供带明确会话 id 的 `list`、`history`、`update` 和 `delete`。它还提供 `deleteInactive`,删除所有会话中全部未运行(界面显示为「已结束」)任务并返回移除的行数;未运行任务从不会安排定时器,因此该删除不重算任何目标,且该批删除是一次持久写入,普通失败时不删除任何行,调用以拒绝结束而不返回计数;后端报告 `unknown-state` 时可能已移除部分行,并会隔离 Host 的 schedule 领域,之后该领域的操作都会拒绝,直到重新打开;任何拒绝都会先发出 `schedule/changed`,客户端重新读取。Remote `list` 和模型 `schedule_list` 仅返回活动任务。目录条目的 `lastDelivery` 仅保留最近一次回执;目录和列表响应均不包含已保存的发送历史。这些操作均不激活 Agent 或读取会话日志。显式删除会阻止后续投递,保留原会话和已经入队的消息,并移除已存储的任务行及其已保存的发送记录:任务从 `list` 和 `catalog` 中消失、不再调度,`history` 对相同的 `(sessionId, id)` 返回 `schedule_not_found`。 归档仍有活动提醒的会话会被拒绝,直到这些提醒停止;选择停止它们会删除全部活动提醒,取消归档不会把它们带回来。 diff --git a/packages/schedule/schedule/src/index.ts b/packages/schedule/schedule/src/index.ts index 82015ff24f..13e9409598 100644 --- a/packages/schedule/schedule/src/index.ts +++ b/packages/schedule/schedule/src/index.ts @@ -372,10 +372,12 @@ export class ScheduleService extends TypertRemoteService { * Ended tasks never schedule another delivery, so the removal changes no pending * target and no timer is recomputed. Rows are enumerated and removed in one FIFO * slot, so a delivery ordered behind this call ends its task after the - * enumeration rather than during it. The batch is one durable write: a failure - * removes nothing and rejects, so the caller never receives a partial removal. - * A backend that reports `unknown-state` may have removed rows, so the service - * announces the change before rejecting and clients re-read the catalog. + * enumeration rather than during it. The batch is one domain write: a normal + * failure rejects with every listed row still stored, while a backend + * `unknown-state` failure may have removed rows and quarantines the Host + * domain, so later schedule operations reject until the domain is reopened. + * Every rejection announces `schedule/changed` because it does not establish + * which rows landed, and clients then re-read the catalog. * @returns Count of removed rows; zero when the Host held no ended task. */ @Remote('deleteInactive') @@ -492,9 +494,11 @@ export class ScheduleService extends TypertRemoteService { /** * Remove rows as one durable batch inside the current queue slot. * - * The batch is one domain write: either every present row is removed and - * announced, or the removal rejects with every row still stored. A rejection - * reason that carries no Error is wrapped with the operation label. + * The batch is one domain write: on success every present row is removed and + * announced. Any rejection announces `schedule/changed` because it does not + * establish which rows landed; a normal failure leaves every row stored, and + * an `unknown-state` failure may have removed some. A rejection reason that + * carries no Error is wrapped with the operation label. * @param ids - rows to remove. * @param label - failure message prefix naming the operation that owned them. * @param rearm - whether the rows were armed, so the pending timer must be recomputed. diff --git a/packages/schedule/schedule/tests/delete-inactive.spec.ts b/packages/schedule/schedule/tests/delete-inactive.spec.ts index 4e15abee18..e46eacca3e 100644 --- a/packages/schedule/schedule/tests/delete-inactive.spec.ts +++ b/packages/schedule/schedule/tests/delete-inactive.spec.ts @@ -167,7 +167,7 @@ it('clears a large ended set in one batch and stays idempotent across repeated c expect(changed).toHaveBeenCalledTimes(rounds + 1) }) -it('removes nothing and reports the batch failure', async () => { +it('reports the batch failure', async () => { const pool = new MemoryMediaPool() const batches: string[][] = [] const backend = gatedScheduleBackend(pool, { diff --git a/packages/storage/storage-domain/src/domain.ts b/packages/storage/storage-domain/src/domain.ts index 4f240d4c99..d10131b482 100644 --- a/packages/storage/storage-domain/src/domain.ts +++ b/packages/storage/storage-domain/src/domain.ts @@ -87,10 +87,11 @@ export interface KvTable { * at call time, so an earlier queued write of the same domain is visible to * it and no other write interleaves. Missing keys are ignored. The in-memory * records reach the committed state before any `domain/changed` listener - * runs. A backend failure rejects with the batch unapplied, leaving every - * record in place. A backend that cannot restore its staged records rejects - * with `unknown-state` and quarantines the domain: synchronous reads and - * every later write reject with that error, while `close()` still works. + * runs. A backend failure normally rejects with the batch unapplied, leaving + * every record in place. A backend that cannot establish or restore the + * batch's state rejects with `unknown-state` and quarantines the domain: + * synchronous reads and every later write reject with that error, while + * `close()` still works. * @param keys - Record keys; duplicates are harmless. * @returns the number of keys that existed and were removed. */ diff --git a/packages/storage/storage-json/README.i18n.yaml b/packages/storage/storage-json/README.i18n.yaml index 96ca9927da..b38a077951 100644 --- a/packages/storage/storage-json/README.i18n.yaml +++ b/packages/storage/storage-json/README.i18n.yaml @@ -24,14 +24,14 @@ en: 7144aa3b5af76874 zh: dfc528d027f86037 /deepseek-ai-dsh-storage-json/use-this-package/observable-behavior: - en: 44e1279b83c7d086 - zh: b7b920b737e9bfe0 + en: 75b7e8c8ae917439 + zh: 30ff8fc276621b7a /deepseek-ai-dsh-storage-json/understand-the-implementation: en: b7d222309b3e5438 zh: 807c7314ee341d94 /deepseek-ai-dsh-storage-json/understand-the-implementation/design-concept: - en: 7baa51f75f15a237 - zh: 1415a2b73940e41d + en: 6cd51dcd7e255715 + zh: 8c4514893f89064a /deepseek-ai-dsh-storage-json/understand-the-implementation/file-formats: en: b3de0a697db4f916 zh: 56b26f7823392bd5 @@ -57,8 +57,8 @@ en: c13c59d13a5f1651 zh: 5aab838ae3ff5324 /deepseek-ai-dsh-storage-json/known-limitations-and-deferred-work: - en: 76b327dc0d93f6ac - zh: f40530debdcd1fb7 + en: 07d386decf38f247 + zh: c3e866b4d8e3372a /deepseek-ai-dsh-storage-json/known-limitations-and-deferred-work/dev-note: en: dd40dd4cb9109aea zh: 3fd79d2242eccadb diff --git a/packages/storage/storage-json/README.md b/packages/storage/storage-json/README.md index 7362b334be..682a63d527 100644 --- a/packages/storage/storage-json/README.md +++ b/packages/storage/storage-json/README.md @@ -53,7 +53,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### Observable behavior -A missing `single` file or `per-record` directory opens as an empty unit and materializes on the first write. In `single`, malformed content rejects with `malformed-medium`, and a different stored version rejects with `version-mismatch`. In `per-record`, each malformed or unreadable document, and each document whose version is outside the descriptor's current and compatible versions, reads as an absent record, so one bad document does not reject the unit. Record keys must match `[a-zA-Z0-9_-]+`; an unsafe key rejects before any file operation. Every resolved write is durable, and operations after close reject with `closed`. +A missing `single` file or `per-record` directory opens as an empty unit and materializes on the first write. In `single`, malformed content rejects with `malformed-medium`, and a different stored version rejects with `version-mismatch`. In `per-record`, each malformed or unreadable document, and each document whose version is outside the descriptor's current and compatible versions, reads as an absent record, so one bad document does not reject the unit. In `per-record`, record keys must match `[a-zA-Z0-9_-]+`; an unsafe key rejects before any file operation, while the `single` layout keeps keys opaque. Single-record writes and `single`-layout publishes are durable once resolved; a `per-record` batch deletion depends on the filesystem persisting directory-entry updates (see Known Limitations). A batch that cannot establish or restore its state rejects with `unknown-state`, after which every later operation except `close()` rejects; calls after close reject with `closed`. An empty `per-record` tree can initialize its declared tables from a valid `/.json` whole-unit document only when the source unit name matches and its version is current or declared compatible. The backend leaves that source file unchanged and stamps migrated records with the current version. A source version outside the accepted set leaves the new tree empty. Any document path in a declared table, or a declared `global.json`, suppresses this initialization for the complete unit, even if that document is unreadable or stale. @@ -70,7 +70,7 @@ The two layouts share atomic publication but assign state ownership differently. ### Design concept - **`single` keeps memory authoritative.** Each write changes the in-memory unit, serializes its complete state, and atomically replaces `.json`. A failed publish restores the prior in-memory value. A batch delete is one such publish: the whole batch is applied or restored together. -- **`per-record` keeps the directory authoritative.** Each put or delete changes one `/
/.json` document, and `loadAll()` rereads the tree. A batch delete renames the batch's documents out of the readable set, restores them if a rename fails, and unlinks the staged copies afterwards. If that restoration fails, the unit rejects with `unknown-state` and every later operation on it rejects. The batch's renames and unlinks are not followed by a directory fsync, so crash durability follows the filesystem's entry-update guarantees: a crash can resurrect a staged document or leave `.deleting` residue. Each document stamps the unit version and carries one record value. +- **`per-record` keeps the directory authoritative.** Each put or delete changes one `/
/.json` document, and `loadAll()` rereads the tree. A batch delete renames the batch's documents out of the readable set, restores them if a rename fails, and unlinks the staged copies afterwards. If that restoration fails, the unit rejects with `unknown-state` and every later operation on it except `close()` rejects. Each document stamps the unit version and carries one record value. - **Publication is durable per call.** A write uses a temporary file, fsync, atomic `rename()` replacement, and a parent-directory fsync on POSIX. The domain layer's write chain supplies ordering across calls. ### File formats @@ -140,6 +140,7 @@ These limits define when this backend is a poor fit or needs special operational - **`single` rewrites the whole unit** — each write republishes the complete unit file; use `per-record` or route the domain to SQLite when this cost is too high. - **No cross-process write locking** — two processes writing the same unit can interleave replacements; writes to the same file use last-completion wins. - **Windows rename without explicit write-through** — durability relies on libuv's `rename()` (`MoveFileExW` with replacement); the stricter Win32 write-through publish helper from the session-log backend is planned to move down here when the `log` facet lands. +- **`per-record` batch delete is not directory-fsynced** — the batch's renames and unlinks rely on the filesystem persisting directory-entry updates; a crash can restore a deleted document under its original key or leave `.deleting` residue. `single` publishes fsync the parent directory. ### Dev Note diff --git a/packages/storage/storage-json/README.zh.md b/packages/storage/storage-json/README.zh.md index b3d5f1c96f..38923f9bfe 100644 --- a/packages/storage/storage-json/README.zh.md +++ b/packages/storage/storage-json/README.zh.md @@ -53,7 +53,7 @@ kind: "package-reference" ### 可观察行为 -缺失的 `single` 文件或 `per-record` 目录会作为空单元打开,并在第一次写入时物化。在 `single` 中,畸形内容以 `malformed-medium` 拒绝,不同的已存版本以 `version-mismatch` 拒绝。在 `per-record` 中,每份畸形或不可读的文档,以及版本不在描述符的当前版本和兼容版本内的文档,都读作记录不存在,因此单个坏文档不会使单元被拒绝。记录键必须匹配 `[a-zA-Z0-9_-]+`;不安全的键在任何文件操作前被拒绝。每次已完成的写入都已持久化,关闭后的操作以 `closed` 拒绝。 +缺失的 `single` 文件或 `per-record` 目录会作为空单元打开,并在第一次写入时物化。在 `single` 中,畸形内容以 `malformed-medium` 拒绝,不同的已存版本以 `version-mismatch` 拒绝。在 `per-record` 中,每份畸形或不可读的文档,以及版本不在描述符的当前版本和兼容版本内的文档,都读作记录不存在,因此单个坏文档不会使单元被拒绝。在 `per-record` 中记录键必须匹配 `[a-zA-Z0-9_-]+`,不安全的键在任何文件操作前被拒绝;`single` 布局保持键不透明。单记录写入与 `single` 布局的发布在 resolve 后即已持久;`per-record` 的批量删除依赖文件系统对目录项更新的持久化(见已知限制)。无法建立或恢复状态的批量删除以 `unknown-state` 拒绝,之后除 `close()` 外对它的所有操作都会拒绝;关闭后的调用以 `closed` 拒绝。 只有当源单元名称匹配,且源版本为当前版本或已声明的兼容版本时,空的 `per-record` 目录树才可以从有效的 `/.json` 整单元文档初始化其已声明表。后端保持该源文件不变,并为迁移的记录写入当前版本戳。接受集合之外的源版本会使新目录树保持为空。已声明表中只要存在任意文档路径,或存在已声明的 `global.json`,就会对整个单元禁止该初始化,即使该文档不可读或版本陈旧。 @@ -70,7 +70,7 @@ kind: "package-reference" ### 设计理念 - **`single` 以内存为权威状态。** 每次写入都会更改内存单元、序列化其完整状态,并以原子方式替换 `.json`。发布失败会恢复先前的内存值。批量删除就是这样一次发布:整批一起应用或一起恢复。 -- **`per-record` 以目录为权威状态。** 每次 put 或 delete 都会更改一个 `/
/.json` 文档,`loadAll()` 则重新读取目录树。每份文档都带有单元版本戳与一条记录值。批量删除先把整批文档改名移出可读集合,任一次改名失败就恢复,之后删除暂存副本;恢复本身失败时该 unit 以 `unknown-state` 拒绝,之后对它的所有操作都会拒绝。批量删除的改名与删除后不做目录 fsync,因此崩溃后的持久性取决于文件系统的目录项更新保证:崩溃可能让暂存文档重新出现,或留下 `.deleting` 残留。 +- **`per-record` 以目录为权威状态。** 每次 put 或 delete 都会更改一个 `/
/.json` 文档,`loadAll()` 则重新读取目录树。每份文档都带有单元版本戳与一条记录值。批量删除先把整批文档改名移出可读集合,任一次改名失败就恢复,之后删除暂存副本;恢复本身失败时该 unit 以 `unknown-state` 拒绝,之后除 `close()` 外对它的所有操作都会拒绝。 - **每次调用都持久发布。** 写入过程使用临时文件、fsync、原子 `rename()` 替换,并在 POSIX 上 fsync 父目录。领域层写入链负责安排跨调用的顺序。 ### 文件格式 @@ -140,6 +140,7 @@ kind: "package-reference" - **`single` 会重写整个单元**——每次写入都重新发布完整单元文件;当此成本过高时,使用 `per-record` 或把领域路由到 SQLite。 - **没有跨进程写锁**——两个进程写入同一单元时可能交错执行替换;对同一文件的写入以最后完成者为准。 - **Windows rename 没有显式 write-through**——持久性依赖 libuv 的 `rename()`(`MoveFileExW` 并启用替换);`log` 分面落地时,计划把会话日志后端更严格的 Win32 write-through 发布辅助函数下移到此处。 +- **`per-record` 批量删除不做目录 fsync**——批量删除的改名与删除依赖文件系统对目录项更新的持久化;崩溃可能让被删记录以原键重新出现,或留下 `.deleting` 残留。`single` 发布会 fsync 父目录。 ### 开发备注 diff --git a/packages/storage/storage-sqlite/README.i18n.yaml b/packages/storage/storage-sqlite/README.i18n.yaml index f7f6e9e2c8..46a2c6628b 100644 --- a/packages/storage/storage-sqlite/README.i18n.yaml +++ b/packages/storage/storage-sqlite/README.i18n.yaml @@ -24,14 +24,14 @@ en: e21da50c3c40bcb8 zh: 0ed58b20e4a32ce9 /deepseek-ai-dsh-storage-sqlite/use-this-package/observable-behavior: - en: b260d7bdf7971f9d - zh: 22dd2f633f507545 + en: eaf8cbb16e426ebe + zh: 7e089537588adb05 /deepseek-ai-dsh-storage-sqlite/understand-the-implementation: en: 6867b52cbb94e572 zh: 187d5d61980c8fe7 /deepseek-ai-dsh-storage-sqlite/understand-the-implementation/design-concept: - en: af6e4b3df6d321cd - zh: 9922901704a6780f + en: 895f7ff7b86a72f2 + zh: 8db4c7702ee4145e /deepseek-ai-dsh-storage-sqlite/understand-the-implementation/open-sequence: en: 50d85e8d8e8b5bc1 zh: 2a7067bd7fb47b35 diff --git a/packages/storage/storage-sqlite/README.md b/packages/storage/storage-sqlite/README.md index b58eec9813..cdf75a86c6 100644 --- a/packages/storage/storage-sqlite/README.md +++ b/packages/storage/storage-sqlite/README.md @@ -54,7 +54,7 @@ Two fields: the database path and the journal mode. `:memory:` opens an in-proce ### Observable behavior -Missing directories and database files are created owner-only (`0o700`/`0o600`); an existing database keeps its modes. A unit whose stored format version differs from its descriptor rejects `version-mismatch`, and a database stamped with a physical layout version other than the current one rejects outright — no migration, pre-release stance. Failures carry stable `StorageError` codes, and writes are durable once resolved. +Missing directories and database files are created owner-only (`0o700`/`0o600`); an existing database keeps its modes. A unit whose stored format version differs from its descriptor rejects `version-mismatch`, and a database stamped with a physical layout version other than the current one rejects outright — no migration, pre-release stance. Failures carry stable `StorageError` codes (`version-mismatch`, `malformed-medium`, `unknown-state`, `closed`); single-record writes and committed batches are durable once resolved. A batch that cannot open or roll back its transaction rejects with `unknown-state`, after which every later operation except `close()` rejects, and calls after close reject `closed`. ----- @@ -69,7 +69,7 @@ The backend is a document-per-row layout over one `node:sqlite` connection, desi ### Design concept - **Document per row.** Each unit table becomes a physical STRICT table `u__
(key TEXT PRIMARY KEY, value TEXT)` whose `value` column holds the record's JSON text; the global singleton lives in a shared `unit_globals` table. One key update touches exactly one row — the reason to route a high-churn domain here. -- **Statement atomicity.** Every single-record write is one prepared statement; a batch delete runs its deletes in one explicit transaction, so the batch commits or rolls back together. A batch that cannot open or roll back its transaction rejects with `unknown-state`, after which every later operation on the unit rejects; write ordering stays the caller's responsibility (the domain layer's write chain). +- **Statement atomicity.** Every single-record write is one prepared statement; a batch delete runs its deletes in one explicit transaction, so the batch commits or rolls back together. A batch that cannot open or roll back its transaction rejects with `unknown-state`, after which every later operation on the unit except `close()` rejects; write ordering stays the caller's responsibility (the domain layer's write chain). - **Names validated before DDL.** Unit and table names must match `UNIT_NAME_RE` before they reach DDL, so no external input is ever interpolated into SQL identifiers. - **Versions fail loud.** The physical layout version lives in `PRAGMA user_version` (fresh databases stamp it last); unit format versions live in the `units` table. Any other stamped value rejects — no migrations. diff --git a/packages/storage/storage-sqlite/README.zh.md b/packages/storage/storage-sqlite/README.zh.md index 40262a66c5..f73d47b32d 100644 --- a/packages/storage/storage-sqlite/README.zh.md +++ b/packages/storage/storage-sqlite/README.zh.md @@ -54,7 +54,7 @@ kind: "package-reference" ### 可观察行为 -缺失的目录与数据库文件会以仅所有者可访问的权限创建(`0o700`/`0o600`);已有数据库保持其既有模式。已存格式版本与描述符不同的单元拒绝 `version-mismatch`,盖有非当前物理布局版本的数据库会直接拒绝——不做迁移,预发布立场。失败携带稳定的 `StorageError` 代码,写入操作完成后即已持久化。 +缺失的目录与数据库文件会以仅所有者可访问的权限创建(`0o700`/`0o600`);已有数据库保持其既有模式。已存格式版本与描述符不同的单元拒绝 `version-mismatch`,盖有非当前物理布局版本的数据库会直接拒绝——不做迁移,预发布立场。失败携带稳定的 `StorageError` 代码(`version-mismatch`、`malformed-medium`、`unknown-state`、`closed`);单记录写入与已提交批次一旦 resolve 即已持久。无法开启或回滚事务的批量删除以 `unknown-state` 拒绝,之后除 `close()` 外对该 unit 的所有操作都会拒绝;关闭后的调用以 `closed` 拒绝。 ----- @@ -69,7 +69,7 @@ kind: "package-reference" ### 设计理念 - **每行一份文档。** 每个单元表都变成一张物理 STRICT 表 `u__
(key TEXT PRIMARY KEY, value TEXT)`,其 `value` 列保存记录的 JSON 文本;全局单例存放在共享的 `unit_globals` 表中。一个键的更新恰好触碰一行——这就是把高频变更领域路由到这里的原因。 -- **语句原子性。** 每个单记录写入都是一条预处理语句;批量删除在一个显式事务中执行其删除,因此整批一起提交或回滚。无法开启或回滚事务的批量删除以 `unknown-state` 拒绝,之后对该 unit 的所有操作都会拒绝;写入顺序仍由调用方负责(领域层的写入链)。 +- **语句原子性。** 每个单记录写入都是一条预处理语句;批量删除在一个显式事务中执行其删除,因此整批一起提交或回滚。无法开启或回滚事务的批量删除以 `unknown-state` 拒绝,之后除 `close()` 外对该 unit 的所有操作都会拒绝;写入顺序仍由调用方负责(领域层的写入链)。 - **名称在 DDL 之前校验。** 单元名与表名在进入 DDL 之前必须匹配 `UNIT_NAME_RE`,因此任何外部输入都不会被插值进 SQL 标识符。 - **版本明确报错。** 物理布局版本存放在 `PRAGMA user_version`(全新数据库最后盖戳);单元格式版本存放在 `units` 表中。任何其他已标记值都会被拒绝——不做迁移。 diff --git a/packages/storage/storage/README.i18n.yaml b/packages/storage/storage/README.i18n.yaml index 67a8db628b..b9c2bfd431 100644 --- a/packages/storage/storage/README.i18n.yaml +++ b/packages/storage/storage/README.i18n.yaml @@ -36,8 +36,8 @@ en: bbdc4833afb85dad zh: 7494de01d5a4aecf /deepseek-ai-dsh-storage/understand-the-implementation/the-backend-contract: - en: 28b6c4e8efb4ffd1 - zh: 4726c72b925c333f + en: f03b47b38dee85dc + zh: 0fe0fc1e580d6ab9 /deepseek-ai-dsh-storage/understand-the-implementation/source-map: en: 506e29aac47a1913 zh: b71dcbf4080e2c49 diff --git a/packages/storage/storage/README.md b/packages/storage/storage/README.md index 4b5f40b5f9..c21c8972e1 100644 --- a/packages/storage/storage/README.md +++ b/packages/storage/storage/README.md @@ -76,7 +76,7 @@ The hub is a pure registration table with two faces, designed so backends and da ### The backend contract -[`src/backend.ts`](src/backend.ts) is the normative contract for backend implementers, checked clause by clause by the shared conformance suite in `tests/contract.ts`. A backend owns exactly one medium and exposes optional data-shape facets; `kv` is the only facet, and opening a unit yields a versioned, globally-singleton schema handle whose single calls are atomic and durable once resolved. Unit and table names must match `UNIT_NAME_RE`; record keys are arbitrary strings that never reach file paths. The unit does not serialize concurrent writes — ordering belongs to the caller — and a stored version differing from the descriptor rejects `version-mismatch` (no migration). +[`src/backend.ts`](src/backend.ts) is the normative contract for backend implementers, checked for the shared clauses by the shared conformance suite in `tests/contract.ts`. A backend owns exactly one medium and exposes optional data-shape facets; `kv` is the only facet, and opening a unit yields a versioned, globally-singleton schema handle whose single calls are atomic, with durability scoped per layout. Unit and table names must match `UNIT_NAME_RE`; record keys are opaque in the `single` layout and path-safe segments in the `per-record` layout. The unit does not serialize concurrent writes — ordering belongs to the caller — and a stored version differing from the descriptor rejects `version-mismatch` (no migration). ### Source map diff --git a/packages/storage/storage/README.zh.md b/packages/storage/storage/README.zh.md index b0bc512148..31f8c76847 100644 --- a/packages/storage/storage/README.zh.md +++ b/packages/storage/storage/README.zh.md @@ -76,7 +76,7 @@ kind: "package-reference" ### 后端约定 -[`src/backend.ts`](src/backend.ts) 是后端实现者的规范性约定,由 `tests/contract.ts` 中的共享一致性套件逐条款检查。一个后端只拥有一种介质,并暴露可选的数据形状分面;`kv` 是唯一的分面,打开单元即可获得一个带版本、全局单例的 schema 句柄,其每次调用均具备原子性,并在完成时保证持久化。单元名与表名必须匹配 `UNIT_NAME_RE`;记录键是任意字符串,绝不进入文件路径。单元不对并发写入做串行化——顺序由调用方负责——介质上记录的版本与描述符不同时拒绝 `version-mismatch`(不做迁移)。 +[`src/backend.ts`](src/backend.ts) 是后端实现者的规范性约定,由 `tests/contract.ts` 中的共享一致性套件检查共享条款。一个后端只拥有一种介质,并暴露可选的数据形状分面;`kv` 是唯一的分面,打开单元即可获得一个带版本、全局单例的 schema 句柄,其每次调用均具备原子性,持久性保证按布局区分。单元名与表名必须匹配 `UNIT_NAME_RE`;记录键在 `single` 布局中不透明,在 `per-record` 布局中则是路径安全片段。单元不对并发写入做串行化——顺序由调用方负责——介质上记录的版本与描述符不同时拒绝 `version-mismatch`(不做迁移)。 ### 源码地图 diff --git a/packages/storage/storage/src/backend.ts b/packages/storage/storage/src/backend.ts index 1dfe6a3c03..9a8a58a5e2 100644 --- a/packages/storage/storage/src/backend.ts +++ b/packages/storage/storage/src/backend.ts @@ -77,10 +77,11 @@ export interface KvUnitDescriptor { * One opened unit. Values are opaque JSON to this layer: no schema, no * events, no domain meaning. The unit does NOT serialize concurrent writes — * write ordering is the caller's responsibility (the domain layer runs one - * write chain per unit); the unit only guarantees that each single call is - * atomic on the medium and durable once resolved (a crash after resolution - * followed by a re-open observes the write). Any call after {@link close} - * rejects with `closed`. + * write chain per unit); each single call is atomic on the medium. Durability + * holds for single-record writes and whole-file publishes. In the + * `per-record` layout batch deletions rely on the filesystem persisting + * directory-entry updates, so a crash can restore a deleted document. Any call + * after {@link close} rejects with `closed`. */ export interface KvUnit { /** @@ -112,15 +113,15 @@ export interface KvUnit { /** * Delete several records durably as one batch. Idempotent: missing keys are * no-ops. Resolves once every listed record is absent from the readable - * state and the backend accepted the batch; a rejection leaves the batch - * unapplied, so no listed record was removed. A backend that cannot + * state and the backend accepted the batch. A rejection normally leaves the + * batch unapplied, so no listed record was removed. A backend that cannot * establish or restore the batch's state rejects with `unknown-state` - * instead: the readable state is then unknown, every later operation on - * that unit rejects with the same error, and only {@link close} still - * releases it. On the `single` layout a failure after the atomic replacement - * (a directory-fsync error) can leave the new file on the medium while the - * caller sees a rejection; the unit keeps its memory consistent and the next - * publish rewrites the file. A backend whose medium can apply a whole batch + * instead: the readable state is then unknown, every later operation on that + * unit rejects with the same error until {@link close} releases it, and + * calls after close reject `closed`. On the `single` layout a + * directory-fsync failure can arrive after the atomic replacement: the new + * file may already hold the batch while the caller sees a rejection, and the + * next publish rewrites it. A backend whose medium can apply a whole batch * in one operation (the `single` document, a row store) must do that instead * of repeating {@link deleteRecord} per key. * @param table - Declared table name. @@ -128,7 +129,7 @@ export interface KvUnit { * layout every key becomes a path segment and must match `[a-zA-Z0-9_-]+` * (an unsafe key rejects before any deletion); in the `single` layout keys * stay opaque. - * @returns resolution after durability. + * @returns resolution after the backend accepted the batch. */ deleteRecords(table: string, keys: readonly string[]): Promise From a0d20b8af01912d1c079fa5224c5a89015c25349 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 22 Oct 2026 19:23:59 +0800 Subject: [PATCH 26/85] test(storage): use the supported throw assertion --- packages/storage/storage-domain/tests/domain.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/storage/storage-domain/tests/domain.spec.ts b/packages/storage/storage-domain/tests/domain.spec.ts index 40edc985e4..a281104e05 100644 --- a/packages/storage/storage-domain/tests/domain.spec.ts +++ b/packages/storage/storage-domain/tests/domain.spec.ts @@ -442,13 +442,13 @@ describe('durability failure', () => { await expect(failing).rejects.toMatchObject({ code: 'unknown-state' }) await expect(queued).rejects.toMatchObject({ code: 'unknown-state' }) // Synchronous reads and later chain jobs see the quarantined domain. - expect(() => table.get('a')).toThrowError(/unknown-state/) - expect(() => [...table.keys()]).toThrowError(/unknown-state/) - expect(() => table.size).toThrowError(/unknown-state/) + expect(() => table.get('a')).toThrow(/unknown-state/) + expect(() => [...table.keys()]).toThrow(/unknown-state/) + expect(() => table.size).toThrow(/unknown-state/) await expect(table.deleteMany(['missing'])).rejects.toMatchObject({ code: 'unknown-state' }) await expect(table.put('c', { label: 'z', count: 3 })).rejects.toMatchObject({ code: 'unknown-state' }) await expect(domain.global.set({ theme: 'dark' })).rejects.toMatchObject({ code: 'unknown-state' }) - expect(() => domain.global.get()).toThrowError(/unknown-state/) + expect(() => domain.global.get()).toThrow(/unknown-state/) // Releasing the domain stays possible. await expect(domain.close()).resolves.toBeUndefined() }) From 358d1c096dfc7a1fc17a853b4e082a9b99f1f35b Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 22 Oct 2026 19:35:23 +0800 Subject: [PATCH 27/85] feat(schedule): lighten the bulk cleanup and clarify its copy --- apps/web/tests/schedule-after.e2e.ts | 4 +-- .../src/client/TaskManagerPage.module.css | 3 +++ .../src/client/TaskManagerPage.tsx | 5 ++-- .../src/client/task-manager-locales.ts | 26 +++++++++---------- .../tests/task-manager-page.client.spec.tsx | 18 +++++++++++++ 5 files changed, 39 insertions(+), 17 deletions(-) diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 973401d897..c6ec427d03 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -1303,8 +1303,8 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { // The confirmation states the Host-side target and removes nothing until it // is confirmed. await clear.click() - const confirmation = page.getByRole('dialog', { name: 'Clear inactive automation tasks?' }) - await confirmation.getByText('2 inactive tasks', { exact: true }).waitFor() + const confirmation = page.getByRole('dialog', { name: 'Clear inactive tasks?' }) + await confirmation.getByText('2 tasks', { exact: true }).waitFor() await confirmation.getByRole('button', { name: 'Cancel', exact: true }).click() expect((await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive')).toHaveLength(2) diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css index a84a2289d2..815181c2fc 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css @@ -1466,6 +1466,9 @@ .deleteButton { color: var(--dsw-alias-state-error-primary); + /* The outlined confirmation button keeps its border in the danger colour so + the destructive action reads as a bordered control, not bare text. */ + --dsh-button-outline-color: var(--dsw-alias-state-error-primary); } .page :focus-visible { diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx index 0d38531291..6498d74073 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx @@ -54,7 +54,7 @@ export function TaskManagerPage(props: TaskManagerPageProps) { const catalog = useCatalog(snapshot => snapshot) const { records, status } = catalog const [search, setSearch] = useState('') - const [statusFilter, setStatusFilter] = useState('all') + const [statusFilter, setStatusFilter] = useState('active') const [selectedId, setSelectedId] = useState(null) const [clearConfirm, setClearConfirm] = useState(false) const [clearing, setClearing] = useState(false) @@ -186,7 +186,7 @@ export function TaskManagerPage(props: TaskManagerPageProps) {
{endedCount > 0 && @@ -93,6 +93,31 @@ export function ConversationSessionHeader({ const unpinned = useEntryPins(ids => ids) const hasEntryActions = useEntryActions(registered => registered) const showTabs = !hideChrome && tabs.length > 1 + const tabRow = useRef(null) + const [visibleViewLimit, setVisibleViewLimit] = useState(1) + // Observe only the available row width; individual tabs are not measured. + useLayoutEffect(() => { + const row = tabRow.current + if (row === null || !hasEntryActions) return + const updateLimit = (): void => { + setVisibleViewLimit(Math.max(1, Math.floor((row.clientWidth - 48) / 120))) + } + updateLimit() + if (typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(updateLimit) + observer.observe(row) + return () => { observer.disconnect() } + }, [showTabs, hasEntryActions]) + const { visibleTabs, moreTabs } = useMemo(() => { + const preferred = [ + ...tabs.filter(tab => tab.id === FIXED_VIEW_ID), + ...tabs.filter(tab => tab.id !== FIXED_VIEW_ID && !unpinned.includes(tab.id)), + ] + const visibleTabs = hasEntryActions ? preferred.slice(0, visibleViewLimit) : preferred + const visibleIds = new Set(visibleTabs.map(tab => tab.id)) + return { visibleTabs, moreTabs: tabs.filter(tab => !visibleIds.has(tab.id)) } + }, [tabs, unpinned, hasEntryActions, visibleViewLimit]) + const currentInMore = moreTabs.some(tab => tab.id === active?.id) return ( <>
@@ -169,10 +194,9 @@ export function ConversationSessionHeader({ {showTabs && ( // data-conversation-tabs: marks the tab strip, which the window-chrome // geometry and the browser coverage lane anchor on. -
+
- {tabs.filter(viewTab => viewTab.id === FIXED_VIEW_ID || viewTab.id === active?.id - || !unpinned.includes(viewTab.id)).map(viewTab => ( + {visibleTabs.map(viewTab => ( ))}
{hasEntryActions && ( viewTab.id !== FIXED_VIEW_ID) - .map(viewTab => ({ id: viewTab.id, label: viewTab.label, pinned: !unpinned.includes(viewTab.id) }))} + label={currentInMore && active !== undefined ? t('session.moreViewsCurrent', { name: active.label }) : t('session.moreViews')} + current={currentInMore} + entries={moreTabs.map(viewTab => ({ + id: viewTab.id, label: viewTab.label, + pinned: !unpinned.includes(viewTab.id), active: viewTab.id === active?.id, + }))} onOpen={selectView} onPinnedChange={setPinned} renderSlot={renderSlot} diff --git a/packages/client/ui-conversation/src/client/view-pins.ts b/packages/client/ui-conversation/src/client/view-pins.ts index aebf97470a..d3d4e1f408 100644 --- a/packages/client/ui-conversation/src/client/view-pins.ts +++ b/packages/client/ui-conversation/src/client/view-pins.ts @@ -1,11 +1,9 @@ -/** Persisted pin choices for the Conversation View tabs. */ +/** In-memory pin choices for the Conversation View tabs. */ import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' /** View whose tab always shows and never enters the customization list. */ export const FIXED_VIEW_ID = 'chat' -/** Root-level key shared with the slot layer's future per-list preferences; Sessions share one list. */ -const STORAGE_KEY = 'dsh.slot.manual-order.conversation.view.v1' const NO_VIEWS: readonly string[] = [] /** Unpinned View ids and their only mutation. */ @@ -13,30 +11,24 @@ export interface ViewPins { /** Unpinned View ids; ids of Views that are not registered stay listed. */ readonly unpinned: ObservableSnapshot /** - * Show or hide one View tab; the fixed View is ignored. + * Pin or unpin one View tab; the fixed View is ignored. A pinned tab still + * enters More when the header's width budget cannot hold it. * @param id - View id. - * @param pinned - Whether the tab shows in the Conversation header. + * @param pinned - Whether the tab is manually pinned. */ readonly setPinned: (id: string, pinned: boolean) => void } -/** Stored content is unchecked; anything but a string array leaves every View pinned. */ -function unpinnedViews(value: unknown): readonly string[] { - if (typeof value !== 'object' || value === null || !('unpinned' in value)) return NO_VIEWS - const { unpinned } = value - return Array.isArray(unpinned) && unpinned.every((id): id is string => typeof id === 'string') ? unpinned : NO_VIEWS -} - /** - * Create the persisted View pin list; the snapshot keeps its reference until the list changes. - * @returns Validated unpinned View ids and the pin mutation. + * Create the in-memory View pin list; the snapshot keeps its reference until the list changes. + * @returns Unpinned View ids and the pin mutation. */ export function createViewPins(): ViewPins { - const store = createSnapshotStore({ unpinned: NO_VIEWS }, { persist: { name: STORAGE_KEY } }) + const store = createSnapshotStore<{ readonly unpinned: readonly string[] }>({ unpinned: NO_VIEWS }) return { - unpinned: { getSnapshot: () => unpinnedViews(store.getSnapshot()), subscribe: listener => store.subscribe(listener) }, + unpinned: { getSnapshot: () => store.getSnapshot().unpinned, subscribe: listener => store.subscribe(listener) }, setPinned: (id, pinned) => { - const current = unpinnedViews(store.getSnapshot()) + const current = store.getSnapshot().unpinned if (id === FIXED_VIEW_ID || pinned !== current.includes(id)) return store.set({ unpinned: pinned ? current.filter(entry => entry !== id) : [...current, id] }) }, diff --git a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx index fa7f2fc4ef..a7b0bfdbdf 100644 --- a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx @@ -44,7 +44,7 @@ function entry( } describe('target-neutral Conversation apply wiring', () => { - it('hands every Session header the same persisted View pins', async () => { + it('hands every Session header the same in-memory View pins without restoring saved choices', async () => { localStorage.setItem('dsh.slot.manual-order.conversation.view.v1', JSON.stringify({ unpinned: ['trajectory'] })) try { const b = await bench() @@ -56,10 +56,10 @@ describe('target-neutral Conversation apply wiring', () => { const inject = header.inject as (sessionId: SessionId) => PinHooks const injected = inject(SID) const first = injected.hooks.entryPins - expect(first.getSnapshot()).toEqual(['trajectory']) + expect(first.getSnapshot()).toEqual([]) expect(inject('session-2' as SessionId).hooks.entryPins).toBe(first) injected.setPinned('timeline', false) - expect(first.getSnapshot()).toEqual(['trajectory', 'timeline']) + expect(first.getSnapshot()).toEqual(['timeline']) const actions = injected.hooks.entryActions const changed = vi.fn() actions.subscribe(changed) diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index b278797d5d..bda16c8a08 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -85,6 +85,7 @@ function fireResize(el: Element): void { afterEach(() => { cleanup() + vi.restoreAllMocks() vi.unstubAllGlobals() resizeObservers.length = 0 }) @@ -144,6 +145,8 @@ function mount( unpinnedViews?: readonly string[] /** Whether the View actions list has registrations. */ entryActions?: boolean + /** Isolate header capability checks from the composer's required layout observers. */ + headerOnly?: boolean } = {}, ) { const sessionId = 'sessionId' in options ? options.sessionId : SID @@ -340,6 +343,7 @@ function mount( const renderFactorySlot = ((_name: string, input: ConversationContentInputProps, factoryOptions?: { slots?: Record ReactNode> }) => { + if (options.headerOnly) return null const common: ConversationViewsProps = { sessionId: SID, SessionProvider, @@ -681,7 +685,30 @@ describe('ConversationRoot resident composer', () => { expect(b.view.getByRole('tablist').hasAttribute('data-conversation-tabs')).toBe(true) }) - it('keeps the selected View tab while it is unpinned and hides it once another View is selected', () => { + it('keeps Chat outside at the narrowest width even when it registers after other Views', () => { + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(180) + const viewTabs = ['trajectory', 'timeline', 'chat', 'notes'].map(id => ({ id, label: id })) + const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs, entryActions: true }) + expect(within(b.view.getByRole('tablist')).getAllByRole('tab').map(tab => tab.textContent)).toEqual(['chat']) + fireEvent.click(b.view.getByRole('button', { name: zh['session.moreViews'] })) + expect(b.actionsOwner().entries.map(entry => entry.id)).toEqual(['trajectory', 'timeline', 'notes']) + act(() => { b.actionsOwner().onOpen('trajectory') }) + expect(b.view.getByRole('tab', { name: 'chat' })).toBeTruthy() + expect(b.view.queryByRole('tab', { name: 'trajectory' })).toBeNull() + expect(b.actionsOwner().entries.find(entry => entry.id === 'trajectory')?.active).toBe(true) + }) + + it('calculates the initial tab budget when ResizeObserver is unavailable', () => { + vi.stubGlobal('ResizeObserver', undefined) + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(300) + const viewTabs = ['chat', 'trajectory', 'timeline'].map(id => ({ id, label: id })) + const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs, entryActions: true, headerOnly: true }) + expect(within(b.view.getByRole('tablist')).getAllByRole('tab').map(tab => tab.textContent)).toEqual(['chat', 'trajectory']) + fireEvent.click(b.view.getByRole('button', { name: zh['session.moreViews'] })) + expect(b.actionsOwner().entries.map(entry => entry.id)).toEqual(['timeline']) + }) + + it('keeps an unpinned View selected without exposing its tab', () => { const viewTabs: ViewTab[] = [ { id: 'chat', label: 'Chat' }, { id: 'trajectory', label: 'Trajectory' }, @@ -691,7 +718,7 @@ describe('ConversationRoot resident composer', () => { act(() => { b.store.actions.setView('trajectory') }) const tabs = (): HTMLElement[] => within(b.view.getByRole('tablist')).getAllByRole('tab') expect(tabs().map(tab => [tab.textContent, tab.getAttribute('aria-selected')])).toEqual([ - ['Chat', 'false'], ['Trajectory', 'true'], ['Timeline', 'false'], + ['Chat', 'false'], ['Timeline', 'false'], ]) expect(b.view.getByTestId('view-trajectory')).toBeTruthy() act(() => { b.store.actions.setView('chat') }) @@ -708,6 +735,75 @@ describe('ConversationRoot resident composer', () => { expect(b.view.queryByRole('menu')).toBeNull() }) + it('reuses the width budget on View roster and selection changes without a window resize listener', () => { + let width = 480 + const readWidth = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => width) + const viewTabs: ViewTab[] = [ + { id: 'chat', label: 'Chat' }, + { id: 'trajectory', label: 'Trajectory' }, + { id: 'timeline', label: 'Timeline' }, + { id: 'inspector', label: 'Inspector' }, + ] + const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs, entryActions: true }) + const external = () => within(b.view.getByRole('tablist')).getAllByRole('tab').map(tab => tab.textContent) + expect(external()).toEqual(['Chat', 'Trajectory', 'Timeline']) + fireEvent.click(b.view.getByRole('button', { name: zh['session.moreViews'] })) + expect(b.actionsOwner().entries.map(entry => entry.id)).toEqual(['inspector']) + act(() => { b.actionsOwner().onOpen('inspector') }) + expect(external()).toEqual(['Chat', 'Trajectory', 'Timeline']) + expect(b.actionsOwner().entries.find(entry => entry.id === 'inspector')?.active).toBe(true) + expect(b.view.getByRole('button', { name: '更多会话视图 · Inspector' })).toBeTruthy() + const reads = readWidth.mock.calls.length + width = 1000 + fireEvent(window, new Event('resize')) + act(() => { b.store.actions.setView('timeline') }) + expect(readWidth).toHaveBeenCalledTimes(reads) + expect(external()).toEqual(['Chat', 'Trajectory', 'Timeline']) + viewTabs.splice(1, 1) + act(() => { b.store.actions.setView('chat') }) + expect(external()).toEqual(['Chat', 'Timeline', 'Inspector']) + expect(readWidth).toHaveBeenCalledTimes(reads) + expect(b.actionsOwner().entries).toEqual([]) + expect(b.setPinned).not.toHaveBeenCalled() + }) + + it('folds and restores Views on container resize without changing selection, and disconnects on unmount', () => { + let width = 1000 + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => width) + const viewTabs = ['chat', 'trajectory', 'timeline', 'inspector', 'notes'].map(id => ({ id, label: id })) + const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs, entryActions: true }) + const row = b.view.getByRole('tablist').parentElement! + const external = () => within(b.view.getByRole('tablist')).getAllByRole('tab').map(tab => tab.textContent) + expect(external()).toEqual(viewTabs.map(tab => tab.label)) + act(() => { b.store.actions.setView('notes') }) + width = 300 + act(() => { fireResize(row) }) + expect(external()).toEqual(['chat', 'trajectory']) + fireEvent.click(b.view.getByRole('button', { name: '更多会话视图 · notes' })) + expect(b.actionsOwner().entries.map(entry => entry.id)).toEqual(['timeline', 'inspector', 'notes']) + expect(b.actionsOwner().entries.find(entry => entry.id === 'notes')?.active).toBe(true) + width = 1000 + act(() => { fireResize(row) }) + expect(external()).toEqual(viewTabs.map(tab => tab.label)) + expect(b.view.getByRole('tab', { name: 'notes' }).getAttribute('aria-selected')).toBe('true') + expect(b.actionsOwner().entries).toEqual([]) + expect(b.setPinned).not.toHaveBeenCalled() + const observers = resizeObservers.filter(observer => observer.targets.includes(row)) + expect(observers).toHaveLength(1) + b.view.unmount() + expect(observers[0]!.targets).toEqual([]) + }) + + it('keeps all five Views outside when the sampled width budget is sufficient', () => { + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1000) + const viewTabs = ['chat', 'trajectory', 'timeline', 'inspector', 'notes'].map(id => ({ id, label: id })) + const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs, entryActions: true }) + expect(within(b.view.getByRole('tablist')).getAllByRole('tab').map(tab => tab.textContent)) + .toEqual(viewTabs.map(tab => tab.label)) + fireEvent.click(b.view.getByRole('button', { name: zh['session.moreViews'] })) + expect(b.actionsOwner().entries).toEqual([]) + }) + it('omits More while the View actions list has no registrations', () => { const b = mount(sessionSnapshotOf()) expect(b.view.getByRole('tablist')).toBeTruthy() @@ -726,11 +822,13 @@ describe('ConversationRoot resident composer', () => { fireEvent.click(more) expect(more.getAttribute('aria-expanded')).toBe('true') expect(within(b.view.getByRole('menu')).getByRole('menuitem', { name: 'Header view action' })).toBeTruthy() - expect(b.actionsOwner().entries).toEqual([{ id: 'trajectory', label: 'Trajectory', pinned: false }]) + expect(b.actionsOwner().entries).toEqual([{ id: 'trajectory', label: 'Trajectory', pinned: false, active: false }]) b.actionsOwner().onPinnedChange('trajectory', true) expect(b.setPinned).toHaveBeenCalledExactlyOnceWith('trajectory', true) act(() => { b.actionsOwner().onOpen('trajectory') }) expect(b.view.getByTestId('view-trajectory')).toBeTruthy() + expect(b.view.queryByRole('tab', { name: 'Trajectory' })).toBeNull() + expect(b.actionsOwner().entries).toEqual([{ id: 'trajectory', label: 'Trajectory', pinned: false, active: true }]) fireEvent.click(b.view.getByRole('menuitem', { name: 'Header view action' })) expect(b.view.queryByRole('menu')).toBeNull() expect(more.getAttribute('aria-expanded')).toBe('false') diff --git a/packages/client/ui-conversation/tests/view-pins.client.spec.ts b/packages/client/ui-conversation/tests/view-pins.client.spec.ts index b42e6171ee..fa4b42d53d 100644 --- a/packages/client/ui-conversation/tests/view-pins.client.spec.ts +++ b/packages/client/ui-conversation/tests/view-pins.client.spec.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -/** Conversation View pins persist one root list for every Session and ignore Chat. */ +/** Conversation View pins share one in-memory root list for every Session and ignore Chat. */ import { afterEach, describe, expect, it, vi } from 'vitest' import { createViewPins } from '../src/client/view-pins.ts' @@ -7,15 +7,15 @@ const KEY = 'dsh.slot.manual-order.conversation.view.v1' afterEach(() => { localStorage.clear() }) describe('Conversation View pins', () => { - it('persists unpinned View ids and restores them in a new instance', () => { + it('keeps unpinned View ids in memory without restoring them in a new instance', () => { const pins = createViewPins() const initial = pins.unpinned.getSnapshot() expect(initial).toEqual([]) expect(pins.unpinned.getSnapshot()).toBe(initial) pins.setPinned('trajectory', false) pins.setPinned('timeline', false) - expect(JSON.parse(localStorage.getItem(KEY) ?? 'null')).toEqual({ unpinned: ['trajectory', 'timeline'] }) - expect(createViewPins().unpinned.getSnapshot()).toEqual(['trajectory', 'timeline']) + expect(localStorage.getItem(KEY)).toBeNull() + expect(createViewPins().unpinned.getSnapshot()).toEqual([]) pins.setPinned('trajectory', true) expect(pins.unpinned.getSnapshot()).toEqual(['timeline']) }) @@ -34,20 +34,14 @@ describe('Conversation View pins', () => { }) it('keeps ids of Views that are not registered', () => { - localStorage.setItem(KEY, JSON.stringify({ unpinned: ['removed-view'] })) const pins = createViewPins() + pins.setPinned('removed-view', false) pins.setPinned('timeline', false) expect(pins.unpinned.getSnapshot()).toEqual(['removed-view', 'timeline']) }) - it.each([ - ['a non-array list', { unpinned: { timeline: true } }], - ['non-string ids', { unpinned: [null] }], - ['a missing list', { pinned: [] }], - ['null', null], - ['an array root', ['timeline']], - ])('reads %s as no unpinned Views with a stable snapshot', (_name, stored) => { - localStorage.setItem(KEY, JSON.stringify(stored)) + it('ignores a legacy stored list instead of restoring it', () => { + localStorage.setItem(KEY, JSON.stringify({ unpinned: ['trajectory'] })) const pins = createViewPins() const snapshot = pins.unpinned.getSnapshot() expect(snapshot).toEqual([]) diff --git a/packages/client/ui-entry-customization/README.i18n.yaml b/packages/client/ui-entry-customization/README.i18n.yaml index 3dc39a2b7d..2be9f86201 100644 --- a/packages/client/ui-entry-customization/README.i18n.yaml +++ b/packages/client/ui-entry-customization/README.i18n.yaml @@ -3,23 +3,23 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-entry-customization/README.md /: - en: 200adf0dd8c9757c - zh: 3d10d49a71289702 + en: 57416280d387ea2b + zh: f9b97e610116bf59 /deepseek-ai-dsh-client-ui-entry-customization: en: ab35194f4a9dafce zh: 7e9e57fde518c3d7 /deepseek-ai-dsh-client-ui-entry-customization/summary: - en: d53e7d71da9fd4d6 - zh: a32b635ec1e572f3 + en: 589199344fe3dd70 + zh: e5e3003c5c4d9479 /deepseek-ai-dsh-client-ui-entry-customization/table-of-contents: en: 76368b6b1da79c80 zh: 52384aee991ac542 /deepseek-ai-dsh-client-ui-entry-customization/use-this-package: - en: 7cc90968634e3f7b - zh: 54f58e17b14f7404 + en: e5ebba75f83c699c + zh: ff0e1229d7b771e0 /deepseek-ai-dsh-client-ui-entry-customization/understand-the-implementation: - en: 3dcb1f29d03d5afe - zh: 5ee39637636cd062 + en: eb2cd6781ce5cc49 + zh: 1ba64cf481e2889a /deepseek-ai-dsh-client-ui-entry-customization/model-experience: en: b891f311c09d536f zh: 68c0f41fbfed0d73 @@ -27,8 +27,8 @@ en: dc50ebf94183efa2 zh: 617803c558367430 /deepseek-ai-dsh-client-ui-entry-customization/known-limitations-and-deferred-work: - en: 2e582c76ffe9d663 - zh: 7e5e49ad3d6f0f83 + en: 5b4914a3ef05fa9c + zh: 9cbe8a17a88ee4d6 /deepseek-ai-dsh-client-ui-entry-customization/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-entry-customization/README.md b/packages/client/ui-entry-customization/README.md index e8ec30ae03..d3dc65d58b 100644 --- a/packages/client/ui-entry-customization/README.md +++ b/packages/client/ui-entry-customization/README.md @@ -1,5 +1,5 @@ --- -description: "Fill the More menus of three navigation regions with pin controls, plugin browsing, and a Creator draft." +description: "Offer plugin browsing and a Creator draft in three navigation regions." kind: "package-reference" --- @@ -11,7 +11,7 @@ Desktop product events use the optional [product analytics service](../product-a ## Summary -Fill the More menus of the left sidebar, Conversation View tabs, and right-sidebar guide with each region's customizable entries, pin buttons, plugin browsing, and a reviewable Creator draft. The Web app loads it by default. Each region draws its own More button and stores its own pin choices; this package stores nothing. +Browse plugins and prepare a reviewable Creator draft from the More menus of the left sidebar, Conversation View tabs, and right-sidebar guide. The Web app loads this package by default. Each region draws its own More button; this package stores nothing. ## Table of Contents @@ -28,11 +28,11 @@ Fill the More menus of the left sidebar, Conversation View tabs, and right-sideb The `@deepseek-ai/dsh-web-app` bundle loads this package by default; it needs no Plugins switch or profile selection. A region shows its More button only while its actions slot has a registration, so unloading this package removes all three buttons. -Open **More** beneath the left navigation entries, **More conversation views** after the Conversation View tabs, or **More tools** after the right-sidebar guide cards. Each menu lists the region's customizable entries in registration order; Plugins, Chat, and Workspace files always show and are not listed. Selecting an entry's name closes the menu and opens the entry without changing its pin state. The separate pin button pins or unpins the region's launcher and keeps the menu open; a filled pin marks a pinned entry. Unpinned entries stay reachable through the menu, and pin choices persist across reloads. Long entry names wrap, except in the More tools menu, where they truncate to one line. +Open **More** beneath the left navigation entries, **More conversation views** after the Conversation View tabs, or **More tools** after the right-sidebar guide cards. Left-sidebar and guide menus offer only **Browse other plugins** and **Create your plugin**. The Conversation menu also lists folded Views before those actions and marks the active View. None of these menus provide pin controls or sorting. -**Browse other plugins** opens the Plugins main list without opening an installation dialog. **Create your plugin** opens a new or reusable blank Session with the Creator preset and adds a short localized request for a plugin in that region to its draft: an empty draft takes the request as its text, and an existing draft keeps its text, references, and attachments with the request appended as a new paragraph. It never sends a message. Review and send the draft yourself. Both actions remain available when a region has no customizable entries; an empty list adds no separator. +**Browse other plugins** opens the Plugins main list without opening an installation dialog. **Create your plugin** opens a new or reusable blank Session with the Creator preset and adds a short localized request for a plugin in that region to its draft: an empty draft takes the request as its text, and an existing draft keeps its text, references, and attachments with the request appended as a new paragraph. It never sends a message. Review and send the draft yourself. Both actions remain available regardless of the region’s entries; a separator appears above browsing only when folded Views precede it. -On Desktop, **Browse other plugins** and **Create your plugin** report `more_menu_item_click` before they act, with `action=browse_plugins` or `action=create_plugin` and the menu's `region`: `sidebar` for **More**, `conversation` for **More conversation views**, and `rightbar` for **More tools**. Opening an entry and changing a pin report nothing, and the menus report no exposure events. Without the analytics service, both actions behave the same and report nothing. +On Desktop, **Browse other plugins** and **Create your plugin** report `more_menu_item_click` before they act, with `action=browse_plugins` or `action=create_plugin` and the menu's `region`: `sidebar` for **More**, `conversation` for **More conversation views**, and `rightbar` for **More tools**. The menus report no exposure events. Without the analytics service, both actions behave the same and report nothing. ----- @@ -42,7 +42,7 @@ On Desktop, **Browse other plugins** and **Create your plugin** report `more_men
Implementation internals — click to expand -[`apply`](src/client/index.ts) registers [`EntryMenuContent`](src/client/EntryMenuContent.tsx) under id `entry-customization` into `sidebar.panellist.actions`, `conversation.session.header.views.actions`, and `sidebar.right.tab.guide.actions` through `ctx.slots.inject`, so each registration follows its region's declaration lifetime. The regions render these slots inside their own `Menu` and supply the entries, their pin state, `onOpen`, `onPinnedChange`, and `onDismiss` as owner props. The rows call `onDismiss` before opening an entry or running a footer action; pin changes leave the menu open. +[`apply`](src/client/index.ts) registers [`EntryMenuContent`](src/client/EntryMenuContent.tsx) under id `entry-customization` into `sidebar.panellist.actions`, `conversation.session.header.views.actions`, and `sidebar.right.tab.guide.actions` through `ctx.slots.inject`, so each registration follows its region's declaration lifetime. The regions render these slots inside their own `Menu`; the rows call `onDismiss` before browsing or creating. Only the Conversation menu consumes its owner’s folded entry list and navigation callback; pin callbacks remain unused. Each registration's `inject` supplies its region and two callbacks. Each callback first calls `ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action })`. Browsing then calls `pluginNavigation.openList()`. Creation then calls `uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })` with the region's localized prompt; it does not go through `ui-agent-preset`, so the preset chip plays no introduction. The [`dsh-web-app` bundle patch](../../bundle/web-app/cordis.patch.yml) mounts the browser plugin. @@ -63,12 +63,12 @@ None; the package neither assembles nor sends model requests. -- If this package is disabled or fails to load after entries were unpinned, those launchers stay hidden and no More menu can restore them. Re-enable the package or clear the region's `dsh.slot.manual-order.*.v1` localStorage key. -- Pin choices live in browser localStorage, which is isolated by protocol, host, and port: changing the Web port or switching between Web and Desktop does not carry them over, and they do not sync across devices. +- More menus provide no visibility controls. Region pin callbacks remain available to other registrations and change only in-memory choices; plugin reload restores defaults. +- Legacy pin values in localStorage are neither read nor written. - **Create your plugin** does not check whether the Creator preset exists. Without it, Session creation fails, the Workspace shows its creation-failure notice, and no draft is prepared. Draft preparation also requires a resolvable Workspace; without one, the existing flow reports that a Workspace is required. -- Entries keep registration order; the menus offer no reordering, and entries cannot move between regions. +- Conversation owns the estimated tab split; these menus provide no sorting. - Conversation controls follow the existing nonblank-Session header and Coding Tools eligibility. Right-sidebar controls require a selected Session and appear on its guide page. -- Only active registered launchers appear; the menus are not an inventory of disabled or uninstalled plugins. +- The menus do not list plugins; use the browsing action to open the Plugins page. ### Dev Note diff --git a/packages/client/ui-entry-customization/README.zh.md b/packages/client/ui-entry-customization/README.zh.md index 4b0169538c..0a468598bd 100644 --- a/packages/client/ui-entry-customization/README.zh.md +++ b/packages/client/ui-entry-customization/README.zh.md @@ -1,5 +1,5 @@ --- -description: "为三个导航区域的“更多”菜单提供 Pin 控件、插件浏览与创造模式草稿。" +description: "为三个导航区域的更多菜单提供插件浏览与创造模式草稿。" kind: "package-reference" --- @@ -11,7 +11,7 @@ kind: "package-reference" ## 概述 -为左侧栏、会话 View 标签和右侧栏起始页的“更多”菜单提供内容:各区域可自定义的入口、Pin 按钮、插件浏览与可审阅的创造模式草稿。Web 应用默认加载本包。各区域自己画“更多”按钮并保存自己的 Pin 状态;本包不保存状态。 +为左侧栏、会话 View 标签和右侧栏起始页的“更多”菜单提供插件浏览与可审阅的创造模式草稿。Web 应用默认加载本包。各区域自己画“更多”按钮;本包不保存状态。 ## 目录 @@ -28,11 +28,11 @@ kind: "package-reference" `@deepseek-ai/dsh-web-app` 组合包默认加载本包,无需在插件页开启或由 profile 选择。区域只在其 actions slot 有注册项时显示“更多”按钮,因此卸载本包会移除三个按钮。 -打开左侧导航入口下方的**更多**、会话 View 标签之后的**更多会话视图**,或右侧栏起始页卡片之后的**更多工具**。每个菜单按注册顺序列出该区域可自定义的入口;插件管理、Chat 和工作区文件始终显示,不列入菜单。点击入口名称会关闭菜单并打开该入口,不改变 Pin 状态。独立的 Pin 按钮在区域中固定或收起该入口,并保持菜单打开;实心 Pin 表示已固定。已收起的入口仍可从菜单打开,Pin 状态在刷新后保留。过长的入口名称会换行,更多工具菜单中则截断为单行。 +打开左侧导航入口下方的**更多**、会话 View 标签之后的**更多会话视图**,或右侧栏起始页卡片之后的**更多工具**。左侧栏和起始页菜单仅提供**浏览更多插件**与**创建你的插件**。会话菜单在这两个操作之前列出收起的 View,并标记当前 View。所有菜单都不提供 Pin 或排序操作。 -**浏览更多插件**进入插件页主列表,不打开安装弹窗。**创建你的插件**用 Creator 预设打开新 Session 或可复用的空白 Session,并把对应区域的简短本地化插件创建请求加入草稿:空草稿直接以该请求为文字,已有草稿保留其文字、引用和附件,请求另起一段追加在后面。它绝不发送消息。请自行审阅并发送草稿。当前区域没有可自定义入口时,两项操作仍然可用;空列表不添加分隔线。 +**浏览更多插件**进入插件页主列表,不打开安装弹窗。**创建你的插件**用 Creator 预设打开新 Session 或可复用的空白 Session,并把对应区域的简短本地化插件创建请求加入草稿:空草稿直接以该请求为文字,已有草稿保留其文字、引用和附件,请求另起一段追加在后面。它绝不发送消息。请自行审阅并发送草稿。两项操作始终可用;仅当前面有收起的 View 时,在浏览操作之前显示分隔线。 -在桌面端,**浏览更多插件**与**创建你的插件**在执行前上报 `more_menu_item_click`,`action` 为 `browse_plugins` 或 `create_plugin`,`region` 为菜单所在区域:**更多**为 `sidebar`,**更多会话视图**为 `conversation`,**更多工具**为 `rightbar`。打开入口和更改 Pin 状态不上报,菜单也不上报曝光事件。没有埋点服务时,两项操作行为不变且不上报。 +在桌面端,**浏览更多插件**与**创建你的插件**在执行前上报 `more_menu_item_click`,`action` 为 `browse_plugins` 或 `create_plugin`,`region` 为菜单所在区域:**更多**为 `sidebar`,**更多会话视图**为 `conversation`,**更多工具**为 `rightbar`。菜单不上报曝光事件。没有埋点服务时,两项操作行为不变且不上报。 ----- @@ -42,7 +42,7 @@ kind: "package-reference"
实现细节——点击展开 -[`apply`](src/client/index.ts) 通过 `ctx.slots.inject` 以 id `entry-customization` 把 [`EntryMenuContent`](src/client/EntryMenuContent.tsx) 注册到 `sidebar.panellist.actions`、`conversation.session.header.views.actions` 和 `sidebar.right.tab.guide.actions`,每个注册项跟随所在区域声明的生命周期。各区域在自己的 `Menu` 中渲染这些 slot,并以 owner props 提供入口、Pin 状态、`onOpen`、`onPinnedChange` 与 `onDismiss`。菜单行先调用 `onDismiss`,再打开入口或执行底部操作;Pin 变化不关闭菜单。 +[`apply`](src/client/index.ts) 通过 `ctx.slots.inject` 以 id `entry-customization` 把 [`EntryMenuContent`](src/client/EntryMenuContent.tsx) 注册到 `sidebar.panellist.actions`、`conversation.session.header.views.actions` 和 `sidebar.right.tab.guide.actions`,每个注册项跟随所在区域声明的生命周期。各区域在自己的 `Menu` 中渲染这些 slot;菜单行先调用 `onDismiss`,再执行浏览或创建操作。仅会话菜单消费 owner 提供的收起入口列表及导航回调;Pin 回调不被消费。 每个注册项的 `inject` 提供所属区域与两个回调。每个回调先调用 `ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action })`。浏览随后调用 `pluginNavigation.openList()`。创建随后按区域取本地化提示语,调用 `uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })`;该调用不经过 `ui-agent-preset`,预设标签不播放介绍动画。[`dsh-web-app` bundle patch](../../bundle/web-app/cordis.patch.yml) 挂载浏览器插件。 @@ -63,12 +63,12 @@ kind: "package-reference" -- 用户收起入口后,若本包被停用或加载失败,被收起的入口保持隐藏,且没有“更多”菜单可以恢复。重新启用本包或清除对应区域的 `dsh.slot.manual-order.*.v1` localStorage 键即可恢复。 -- Pin 状态保存在浏览器 localStorage,按协议、主机和端口隔离:更换 Web 端口或在 Web 与 Desktop 之间切换时不共享,也不跨设备同步。 +- 更多菜单不提供入口显隐管理。其他注册项仍可调用区域 Pin 回调,仅修改内存中的选择;插件重新加载后恢复默认值。 +- 不读取或写入 localStorage 中的旧 Pin 值。 - **创建你的插件**不检查 Creator 预设是否存在。预设缺失时 Session 创建失败,Workspace 显示创建失败提示,不准备草稿。草稿准备还要求可确定目标 Workspace;没有目标时,现有流程提示需要 Workspace。 -- 入口保持注册顺序;菜单不提供排序,入口也不能跨区域移动。 +- 会话区域负责按估算值分组;菜单不提供排序。 - 会话控件遵循现有非空 Session 头部与代码工作工具的可用性规则。右侧栏控件要求已选择 Session,并显示在其起始页。 -- 仅显示已激活、已注册的入口;菜单不是已停用或未安装插件的清单。 +- 菜单不列出插件清单;使用浏览操作进入插件页。 ### 开发备注 diff --git a/packages/client/ui-entry-customization/src/client/EntryMenu.module.css b/packages/client/ui-entry-customization/src/client/EntryMenu.module.css index 82ce8f3f88..d7d1c3a298 100644 --- a/packages/client/ui-entry-customization/src/client/EntryMenu.module.css +++ b/packages/client/ui-entry-customization/src/client/EntryMenu.module.css @@ -1,12 +1,3 @@ -.entryRow { position: relative; } -.entryName { display: flex; align-items: center; gap: 8px; padding-right: 28px; } +.entryName { display: flex; align-items: center; gap: 8px; } .entryTitle { min-width: 0; overflow-wrap: anywhere; white-space: normal; } -.singleLine .entryTitle { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.pinAnchor { position: absolute; right: 6px; top: 0; bottom: 0; display: flex; align-items: center; } -.pinButton { width: 22px; height: 22px; padding: 0; color: var(--dsw-alias-label-tertiary); } - -@media (hover: hover) { - .pinButton:hover { color: var(--dsw-alias-label-primary); } - .pinAnchor[data-pinned='false'] { opacity: 0; } - .entryRow:hover .pinAnchor, .entryRow:focus-within .pinAnchor { opacity: 1; } -} +.activeMark { flex: none; margin-left: auto; } diff --git a/packages/client/ui-entry-customization/src/client/EntryMenuContent.tsx b/packages/client/ui-entry-customization/src/client/EntryMenuContent.tsx index 0370d547ee..d8844281ab 100644 --- a/packages/client/ui-entry-customization/src/client/EntryMenuContent.tsx +++ b/packages/client/ui-entry-customization/src/client/EntryMenuContent.tsx @@ -1,17 +1,17 @@ -/** Rows of a navigation region's More menu: customizable entries, then plugin discovery and creation. */ +/** Folded Conversation Views and plugin discovery and creation actions. */ import { - IconPluginPinwheelOutlineRegular, IconPlusOutlineRegular, MenuItemButton, + IconCheckOutlineRegular, IconPluginPinwheelOutlineRegular, IconPlusOutlineRegular, MenuItemButton, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' -import { EntryPickerRow } from './EntryPickerRow.tsx' +import css from './EntryMenu.module.css' /** Navigation region whose More menu hosts one registration. */ export type EntryRegion = 'sidebar' | 'conversation' | 'rightbar' /** Region identity and footer actions injected into each registration. */ export interface EntryMenuInjected { - /** Region of this registration; selects pin copy and the Creator prompt. */ + /** Region of this registration; selects analytics attributes and the Creator prompt. */ readonly region: EntryRegion /** Open the Plugins main list. */ readonly browsePlugins: () => void @@ -24,22 +24,23 @@ type EntryMenuContentProps = PropsRuntime<'sidebar.panellist.actions'> & PropsLocale<'entryCustomization'> & InjectFace /** - * Render the region's entries with pin actions, then the discovery and Creator rows. - * @param props - Owner entries and callbacks, injected region actions, and localized copy. + * Render folded Conversation Views followed by discovery and Creator actions, without pin controls. + * @param props - Owner dismissal callback, injected actions, and localized copy. * @returns Menu rows for the region's More menu. */ export function EntryMenuContent({ - entries, onOpen, onPinnedChange, onDismiss, region, browsePlugins, createPlugin, t, + entries, onOpen, region, onDismiss, browsePlugins, createPlugin, t, }: EntryMenuContentProps) { + const moreEntries = region === 'conversation' ? entries : [] return <> - {entries.map((entry) => { - const pinLabel = t(`${entry.pinned ? 'unpin' : 'pin'}.${region}`) - return { onDismiss(); onOpen(entry.id) }} - onPin={() => { onPinnedChange(entry.id, !entry.pinned) }} /> - })} - } separatorBefore={entries.length > 0} + {moreEntries.map(entry => { onDismiss(); onOpen(entry.id) }}> + + {entry.label} + {entry.active === true && } + + )} + 0} icon={} onSelect={() => { onDismiss(); browsePlugins() }}> {t('install')} diff --git a/packages/client/ui-entry-customization/src/client/EntryPickerRow.tsx b/packages/client/ui-entry-customization/src/client/EntryPickerRow.tsx deleted file mode 100644 index 4148de08f9..0000000000 --- a/packages/client/ui-entry-customization/src/client/EntryPickerRow.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/** A launcher row separates navigation from its independently focusable pin action. */ -import clsx from 'clsx' -import { - Button, IconPinFillRegular, IconPinOutlineRegular, MenuItemButton, Tooltip, -} from '@deepseek-ai/dsh-client-ui-primitives' -import css from './EntryMenu.module.css' - -interface EntryPickerRowProps { - readonly label: string - readonly pinned: boolean - readonly pinLabel: string - readonly pinAccessibleLabel: string - /** Truncate the label to one line instead of wrapping it. */ - readonly singleLine: boolean - readonly onOpen: () => void - readonly onPin: () => void -} - -/** - * Render two sibling controls so pinning never opens the entry. - * @param props - Entry label, pin state, localized action names and independent callbacks. - * @returns A shared menu row with a trailing pin button. - */ -export function EntryPickerRow({ - label, pinned, pinLabel, pinAccessibleLabel, singleLine, onOpen, onPin, -}: EntryPickerRowProps) { - return
- - - {label} - - - - - - - -
-} diff --git a/packages/client/ui-entry-customization/src/client/index.ts b/packages/client/ui-entry-customization/src/client/index.ts index 0becd3802f..bde0b46701 100644 --- a/packages/client/ui-entry-customization/src/client/index.ts +++ b/packages/client/ui-entry-customization/src/client/index.ts @@ -13,7 +13,7 @@ import { en, zh } from './locales.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { - /** More menu rows: pin actions, footer actions, and Creator draft prompts. */ + /** More menu rows: navigation actions, and Creator draft prompts. */ entryCustomization: keyof typeof en } } diff --git a/packages/client/ui-entry-customization/src/client/locales.ts b/packages/client/ui-entry-customization/src/client/locales.ts index 5b7692411a..f35a54248f 100644 --- a/packages/client/ui-entry-customization/src/client/locales.ts +++ b/packages/client/ui-entry-customization/src/client/locales.ts @@ -1,12 +1,5 @@ -/** Copy for the More menu rows: pin actions, footer actions, and Creator draft prompts per region. */ +/** Copy for the More menu rows: navigation actions, and Creator draft prompts per region. */ export const en = { - 'pin.sidebar': 'Pin to left sidebar', - 'unpin.sidebar': 'Unpin from left sidebar', - 'pin.conversation': 'Pin to conversation tabs', - 'unpin.conversation': 'Unpin from conversation tabs', - 'pin.rightbar': 'Pin to right sidebar', - 'unpin.rightbar': 'Unpin from right sidebar', - pinLabel: '{action}: {name}', install: 'Browse other plugins', create: 'Create your plugin', 'createPrompt.sidebar': 'Create a DSH plugin with a left sidebar entry.', @@ -16,13 +9,6 @@ export const en = { /** Chinese counterpart of the entry-customization dictionary. */ export const zh: Record = { - 'pin.sidebar': '固定到左侧栏', - 'unpin.sidebar': '从左侧栏取消固定', - 'pin.conversation': '固定到会话标签栏', - 'unpin.conversation': '从会话标签栏取消固定', - 'pin.rightbar': '固定到右侧栏', - 'unpin.rightbar': '从右侧栏取消固定', - pinLabel: '{action}:{name}', install: '浏览更多插件', create: '创建你的插件', 'createPrompt.sidebar': '创建一个带左侧栏入口的 DSH 插件。', diff --git a/packages/client/ui-entry-customization/tests/menu.client.spec.tsx b/packages/client/ui-entry-customization/tests/menu.client.spec.tsx index 7171146a8e..4e1dd41cde 100644 --- a/packages/client/ui-entry-customization/tests/menu.client.spec.tsx +++ b/packages/client/ui-entry-customization/tests/menu.client.spec.tsx @@ -1,8 +1,8 @@ // @vitest-environment jsdom -/** The plugin fills each region's More menu with entry rows and the discovery and Creator actions. */ +/** The plugin fills each region's More menu with the discovery and Creator actions. */ import type { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, fireEvent, render, within } from '@testing-library/react' +import { cleanup, fireEvent, render, within } from '@testing-library/react' import type { ComponentProps } from 'react' import { useState } from 'react' import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' @@ -30,10 +30,9 @@ function content(overrides: Partial = {}) { onDismiss: vi.fn(), browsePlugins: vi.fn(), createPlugin: vi.fn(), } - // Framework hooks stay unused stubs; this isolated row set reads only its owner, inject, and locale shares. const props = { entries: [{ id: 'notes', label: 'Notes', pinned: true }, { id: 'tasks', label: 'Tasks', pinned: false }], - region: 'sidebar', t: makeTranslate(en), renderFactorySlot: unused, + region: 'sidebar', t: makeTranslate(en), usePanelInfo: unused, useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused, useResource: unused, useWorkspaces: unused, ...callbacks, @@ -44,53 +43,45 @@ function content(overrides: Partial = {}) { } describe('More menu rows', () => { - it('separates opening from pinning and reports the inverted pin state', () => { - const b = content() - expect(b.view.getAllByRole('menuitem').map(row => row.getAttribute('aria-label') ?? row.textContent)).toEqual([ - 'Notes', 'Unpin from left sidebar: Notes', 'Tasks', 'Pin to left sidebar: Tasks', 'Browse other plugins', 'Create your plugin', - ]) - expect(b.view.getAllByRole('separator')).toHaveLength(1) - fireEvent.click(b.view.getByRole('menuitem', { name: 'Unpin from left sidebar: Notes' })) - fireEvent.click(b.view.getByRole('menuitem', { name: 'Pin to left sidebar: Tasks' })) - expect(b.onPinnedChange).toHaveBeenNthCalledWith(1, 'notes', false) - expect(b.onPinnedChange).toHaveBeenNthCalledWith(2, 'tasks', true) - expect(b.onDismiss).not.toHaveBeenCalled() - expect(b.onOpen).not.toHaveBeenCalled() - fireEvent.click(b.view.getByRole('menuitem', { name: 'Tasks' })) - expect(b.onDismiss).toHaveBeenCalledOnce() - expect(b.onOpen).toHaveBeenCalledExactlyOnceWith('tasks') - expect(b.onDismiss.mock.invocationCallOrder[0]).toBeLessThan(b.onOpen.mock.invocationCallOrder[0]!) - }) - - it.each([ - ['conversation', 'Pin to conversation tabs: Tasks'], - ['rightbar', 'Pin to right sidebar: Tasks'], - ] as const)('names the %s pin destination', (region, name) => { - const { view } = content({ region }) - expect(view.getByRole('menuitem', { name })).toBeTruthy() - }) - - it('keeps only the discovery and Creator actions, without a separator, for an empty roster', () => { - const b = content({ entries: [], region: 'rightbar' }) + it.each(['sidebar', 'rightbar'] as const)('shows only discovery and creation in %s regardless of entries', (region) => { + const b = content({ region, entries: [ + { id: 'notes', label: 'Notes', pinned: true, active: true }, + { id: 'tasks', label: 'Tasks', pinned: false }, + ] }) expect(b.view.getAllByRole('menuitem').map(row => row.textContent)).toEqual(['Browse other plugins', 'Create your plugin']) expect(b.view.queryByRole('separator')).toBeNull() fireEvent.click(b.view.getByRole('menuitem', { name: 'Browse other plugins' })) expect(b.browsePlugins).toHaveBeenCalledExactlyOnceWith() + expect(b.onDismiss.mock.invocationCallOrder[0]).toBeLessThan(b.browsePlugins.mock.invocationCallOrder[0]!) fireEvent.click(b.view.getByRole('menuitem', { name: 'Create your plugin' })) expect(b.createPlugin).toHaveBeenCalledExactlyOnceWith() - expect(b.onDismiss).toHaveBeenCalledTimes(2) + expect(b.onDismiss.mock.invocationCallOrder[1]).toBeLessThan(b.createPlugin.mock.invocationCallOrder[0]!) + expect(b.onOpen).not.toHaveBeenCalled() + expect(b.onPinnedChange).not.toHaveBeenCalled() }) - it('shows the pin hint after the shared tooltip delay outside the menu card', () => { - const { view } = content() - const unpin = view.getByRole('menuitem', { name: 'Unpin from left sidebar: Notes' }) - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - fireEvent.mouseEnter(unpin) - act(() => { vi.advanceTimersByTime(499) }) - expect(view.queryByRole('tooltip', { hidden: true })).toBeNull() - act(() => { vi.advanceTimersByTime(1) }) - expect(view.getByRole('tooltip', { hidden: true }).closest('[role="menu"]')).toBeNull() - fireEvent.mouseLeave(unpin) + it('opens folded Conversation Views and marks the active View without pin controls', () => { + const b = content({ region: 'conversation', entries: [ + { id: 'notes', label: 'Notes', pinned: true, active: true }, + { id: 'tasks', label: 'Tasks', pinned: true, active: false }, + ] }) + expect(b.view.getAllByRole('menuitem').map(row => row.textContent)) + .toEqual(['Notes', 'Tasks', 'Browse other plugins', 'Create your plugin']) + const currentRow = b.view.getByRole('menuitem', { name: 'Notes' }) + expect(currentRow.getAttribute('aria-current')).toBe('page') + expect(currentRow.querySelector('svg')?.previousElementSibling?.textContent).toBe('Notes') + expect(b.view.getByRole('menuitem', { name: 'Tasks' }).hasAttribute('aria-current')).toBe(false) + expect(b.view.getAllByRole('separator')).toHaveLength(1) + fireEvent.click(b.view.getByRole('menuitem', { name: 'Tasks' })) + expect(b.onOpen).toHaveBeenCalledExactlyOnceWith('tasks') + expect(b.onDismiss.mock.invocationCallOrder[0]).toBeLessThan(b.onOpen.mock.invocationCallOrder[0]!) + expect(b.onPinnedChange).not.toHaveBeenCalled() + }) + + it('keeps both actions for an empty roster', () => { + const b = content({ entries: [] }) + expect(b.view.getAllByRole('menuitem').map(row => row.textContent)).toEqual(['Browse other plugins', 'Create your plugin']) + expect(b.view.queryByRole('separator')).toBeNull() }) }) @@ -101,7 +92,7 @@ function Frame({ renderSlot }: PropsRenderSlots) { return <> {SEATS.map(seat =>
{renderSlot(seat, { - entries, + entries: seat === 'conversation.session.header.views.actions' ? [] : entries, onOpen: (id) => { setOpened(`${seat}:${id}`) }, onPinnedChange: (_id, next) => { setPinned(next) }, onDismiss: () => {}, @@ -142,15 +133,10 @@ describe('More menu registrations', () => { const b = await bench() for (const seat of SEATS) { expect(b.runtime.slots.entries(seat).map(entry => entry.options.id)).toEqual(['entry-customization']) - expect(within(b.view.getByRole('menu', { name: seat })).getByRole('menuitem', { name: 'Notes' })).toBeTruthy() + expect(within(b.view.getByRole('menu', { name: seat })).getAllByRole('menuitem').map(row => row.textContent)) + .toEqual(['Browse other plugins', 'Create your plugin']) } const sidebar = within(b.view.getByRole('menu', { name: 'sidebar.panellist.actions' })) - fireEvent.click(sidebar.getByRole('menuitem', { name: 'Unpin from left sidebar: Notes' })) - expect(within(b.view.getByRole('menu', { name: 'sidebar.right.tab.guide.actions' })) - .getByRole('menuitem', { name: 'Pin to right sidebar: Notes' })).toBeTruthy() - fireEvent.click(within(b.view.getByRole('menu', { name: 'conversation.session.header.views.actions' })) - .getByRole('menuitem', { name: 'Notes' })) - expect(b.view.getByTestId('opened').textContent).toBe('conversation.session.header.views.actions:notes') fireEvent.click(sidebar.getByRole('menuitem', { name: 'Browse other plugins' })) expect(b.openList).toHaveBeenCalledExactlyOnceWith() await b.plugin.dispose() @@ -192,15 +178,6 @@ describe('More menu registrations', () => { expect(b.track).toHaveBeenCalledTimes(2) }) - it('reports neither entry opening nor pin changes', async () => { - const b = await bench() - const menu = within(b.view.getByRole('menu', { name: 'sidebar.panellist.actions' })) - fireEvent.click(menu.getByRole('menuitem', { name: 'Unpin from left sidebar: Notes' })) - fireEvent.click(menu.getByRole('menuitem', { name: 'Notes' })) - expect(b.view.getByTestId('opened').textContent).toBe('sidebar.panellist.actions:notes') - expect(b.track).not.toHaveBeenCalled() - }) - it('keeps both footer actions working without the analytics service', async () => { const b = await bench('en', { analytics: false }) const menu = within(b.view.getByRole('menu', { name: 'sidebar.right.tab.guide.actions' })) diff --git a/packages/client/ui-entry-customization/tests/pin-styles.client.spec.ts b/packages/client/ui-entry-customization/tests/pin-styles.client.spec.ts deleted file mode 100644 index c30d3ee705..0000000000 --- a/packages/client/ui-entry-customization/tests/pin-styles.client.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** Pin controls use Session-action ink without changing the navigation control's dimensions. */ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' - -const css = readFileSync(new URL('../src/client/EntryMenu.module.css', import.meta.url), 'utf8') - -describe('entry pin styling', () => { - it('uses tertiary ink for both pin states and primary ink on pointer hover', () => { - expect(css).toMatch(/\.pinButton\s*\{[^}]*color:\s*var\(--dsw-alias-label-tertiary\)/) - expect(css).toMatch(/\.pinButton:hover\s*\{[^}]*color:\s*var\(--dsw-alias-label-primary\)/) - expect(css).not.toMatch(/\[data-pinned=['"]true['"]\]\s+\.pinButton\s*\{[^}]*color:/) - }) - - it('centers the pin without creating a transformed tooltip container', () => { - const pinAnchor = css.match(/\.pinAnchor\s*\{([^}]*)\}/)?.[1] - expect(pinAnchor).toBeDefined() - expect(pinAnchor).toContain('align-items: center') - expect(pinAnchor).not.toContain('transform:') - }) -}) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 3399a8e620..e12048274a 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -18,8 +18,8 @@ en: 7bae72c97d0449f5 zh: 4589ecc2b216edc5 /deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog: - en: 75315591e4aa1202 - zh: 2b1920468ccb715d + en: c804a44a782227cb + zh: 14b53da9bf298c5f /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: en: 09c61f61c560f8a2 zh: 895ac73add1199b8 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 48496db16d..76449c4f59 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -46,7 +46,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. | | `Input` | Single-line text entry for search boxes and inline forms. Its ref targets the native input for focus and is cleared on unmount. | | `InlineEditor` | Autofocused multi-line inline editor for short drafts such as queued messages and goals. It grows with its text and its width up to six lines, then scrolls; Enter saves, Shift+Enter breaks the line, Enter during IME composition does nothing, and Escape cancels. The caller supplies the localized `label` and rebinds the scrollbar on an elevated surface. | -| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. | +| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. `MenuItemButton.current` exposes the current page or view through `aria-current` without changing selection or appearance. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. | | `MenuGroup`, `observeStickyMenuGroups` | Localized, accessible groups for custom menus and listboxes, with shared sticky-heading styling and native intersection/size observation; the caller owns the observer's lifetime. | | `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. | | `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index b9e678b6b1..0ad5b2297b 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -46,7 +46,7 @@ kind: "package-library" | `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 | | `Input` | 单行文本输入,用于搜索框与行内表单。ref 指向原生输入框,供焦点控制使用,并在卸载时清空。 | | `InlineEditor` | 自动聚焦的多行行内编辑器,用于排队消息、goal 等短草稿。它随文本和宽度增高,最多六行,之后滚动;Enter 保存,Shift+Enter 换行,输入法组合中的 Enter 不生效,Escape 取消。调用方提供本地化 `label`,并在 elevated 表面上重绑滚动条。 | -| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 | +| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。`MenuItemButton.current` 通过 `aria-current` 标识当前页面或视图,不改变选中状态或外观。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 | | `MenuGroup`、`observeStickyMenuGroups` | 为自定义菜单与列表框提供本地化、可访问的分组,共用吸顶标题样式与原生交叉/尺寸观察;调用方负责观察器的生命周期。 | | `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 | | `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。 | diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index 0f1225fc42..87e76287c7 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -51,6 +51,8 @@ export interface MenuItemButtonProps { shortcut?: MenuItem['shortcut'] /** Leading icon (figma .Menu_cell gap 8). */ icon?: ReactNode + /** Whether this row identifies the currently displayed page or view. */ + current?: boolean /** Whether the row cannot be activated. */ disabled?: boolean /** Destructive row: error-colored text/icon and danger hover fill. */ @@ -75,6 +77,7 @@ export interface MenuItemButtonProps { * @param props.children - visible row label. * @param props.shortcut - effective key labels and accessible combination. * @param props.icon - optional leading icon. + * @param props.current - whether the row identifies the currently displayed page or view. * @param props.disabled - whether the row cannot be activated. * @param props.danger - whether to use the destructive row colors. * @param props.separatorBefore - whether this row starts a new group (hairline above it). @@ -82,7 +85,7 @@ export interface MenuItemButtonProps { * @returns one menu-item row. */ export function MenuItemButton({ - children, shortcut, icon, disabled = false, danger = false, separatorBefore = false, onSelect, + children, shortcut, icon, current, disabled = false, danger = false, separatorBefore = false, onSelect, }: MenuItemButtonProps) { return (
@@ -93,6 +96,7 @@ export function MenuItemButton({ className={clsx(css.item, danger && css.danger)} disabled={disabled} aria-keyshortcuts={shortcut?.aria} + aria-current={current === true ? 'page' : undefined} onClick={onSelect} > {icon !== undefined && {icon}} diff --git a/packages/client/ui-primitives/tests/atoms.client.spec.tsx b/packages/client/ui-primitives/tests/atoms.client.spec.tsx index bc48ea0d38..d63a757110 100644 --- a/packages/client/ui-primitives/tests/atoms.client.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.client.spec.tsx @@ -80,6 +80,21 @@ describe('Input', () => { }) }) +describe('MenuItemButton', () => { + it('exposes the current page without preventing activation and clears it when selection moves', () => { + const onSelect = vi.fn() + const { rerender } = render(Notes) + const row = screen.getByRole('menuitem', { name: 'Notes' }) + expect(row.getAttribute('aria-current')).toBe('page') + fireEvent.click(row) + expect(onSelect).toHaveBeenCalledOnce() + rerender(Notes) + expect(row.hasAttribute('aria-current')).toBe(false) + rerender(Notes) + expect(row.hasAttribute('aria-current')).toBe(false) + }) +}) + describe('Menu', () => { const items = [ { id: 'a', label: 'Alpha' }, diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml index 4c93ea2e93..6f7b7c2eb9 100644 --- a/packages/client/ui-sidebar-right/README.i18n.yaml +++ b/packages/client/ui-sidebar-right/README.i18n.yaml @@ -36,8 +36,8 @@ en: 717b86e05b44ebdd zh: 0f4f694d0ea1d7f8 /deepseek-ai-dsh-client-ui-sidebar-right/the-guide: - en: dfb9e0911e4278f1 - zh: e395e10f61ad040d + en: 0a77be5c16ef83c6 + zh: 532545fef09c3e66 /deepseek-ai-dsh-client-ui-sidebar-right/copy: en: 4315a1c03ca5fef3 zh: c09a09f1d9b99107 diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md index ea0269b716..833d08b317 100644 --- a/packages/client/ui-sidebar-right/README.md +++ b/packages/client/ui-sidebar-right/README.md @@ -114,7 +114,7 @@ Close and refresh commands resolve the focused pane and tab occurrence. Page bod Each `guide` entry has an `id` unique within its provider. The guide renders `sidebar.right.tab.guide.entry` by the active definition id, forwarding the entry id, resolved title and optional description, plus the framework `useTabInfo` hook. A provider can register a custom card under its definition id; otherwise the standard icon/title button renders. The sidebar owns width and placement, while custom components own their markup and interaction. Guide cards use the shared R20 radius, including hover fills and provider-owned split buttons. Entry identity remains stable across sorting and unrelated registrations. Unboxed shortcut text aligns at the right. The terminal shell menu sits beside its entry title. -While the root-scoped `sidebar.right.tab.guide.actions` list has registrations, a **More tools** card follows the guide cards. Its menu end-aligns to the card, is at least as wide as the card, and grows with its rows up to 480px or the viewport width minus 24px. The menu rows are that list's registrations. Each receives `entries` (cards in registry order with `id`, `label`, and `pinned`; kind `files` is never listed), `onOpen(id)`, which opens the card's tab type in the guide's place, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The guide keeps one root list of unpinned card ids, each `kind:entry id`, in browser `localStorage` under `dsh.slot.manual-order.sidebar.right.tab.guide.list.v1`, stored as `{ "unpinned": string[] }`; the key reserves the name of a future guide list slot. The kind identifies a card instead of the provider id, so a pin choice survives an extension taking over that kind with the same entry id. Cards of kind `files` always show. Unpinning hides only the card; it closes no tab and changes neither tab-type registration nor the default page. Description density follows the visible card count. Ids of cards that are not registered stay stored; stored content other than a string array shows every card. +While the root-scoped `sidebar.right.tab.guide.actions` list has registrations, a **More tools** card follows the guide cards. Its menu end-aligns to the card, is at least as wide as the card, and grows with its rows up to 480px or the viewport width minus 24px. The menu rows are that list's registrations. Each receives `entries` (cards in registry order with `id`, `label`, and `pinned`; kind `files` is never listed), `onOpen(id)`, which opens the card's tab type in the guide's place, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The guide keeps an in-memory list of unpinned card ids, each `kind:entry id`. Plugin reload resets pin choices; legacy localStorage values are ignored. The default More menu only offers plugin browsing and creation; pin callbacks remain available to other registrations. The kind identifies a card instead of the provider id, so a pin choice survives an extension taking over that kind with the same entry id. Cards of kind `files` always show. Unpinning hides only the card; it closes no tab and changes neither tab-type registration nor the default page. Description density follows the visible card count. Unregistered card ids remain in memory until plugin reload. A tab type can declare `multiple: true` for independent instances such as terminals. Each open receives a distinct content address, so placement and docking preserve those instances; ordinary pages retain per-pane uniqueness. diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md index 052b38e2c8..d16b18f161 100644 --- a/packages/client/ui-sidebar-right/README.zh.md +++ b/packages/client/ui-sidebar-right/README.zh.md @@ -114,7 +114,7 @@ Tab 域按(Session,Tab id)保留品牌化 occurrence id、导航、中止 每个 `guide` 入口具有 provider 内唯一的 `id`。开始页按当前生效的定义 id 渲染 `sidebar.right.tab.guide.entry`,传入入口 id、解析后的标题、可选描述,以及框架提供的 `useTabInfo` hook。provider 可按自己的定义 id 注册自定义卡片;未注册时使用默认图标与标题按钮。侧栏负责宽度和排列,自定义组件负责内部结构和交互。引导卡片统一使用 R20,包含悬停底色与提供方自有的分体按钮。排序或其他注册变化不会改变入口身份。无背景的快捷键文字右对齐。终端 Shell 菜单位于入口标题旁。 -root 作用域的 `sidebar.right.tab.guide.actions` list 有注册项时,引导卡片之后显示**更多工具**卡片。它的菜单与卡片末端对齐,不窄于卡片,并随菜单行增宽,最大为 480px 与视口宽度减 24px 中的较小值。菜单行为该 list 的注册项。每个注册项接收 `entries`(按注册表顺序的卡片,含 `id`、`label` 与 `pinned`;不含 kind 为 `files` 的卡片)、在引导页位置打开卡片标签类型的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。引导页在浏览器 `localStorage` 的 `dsh.slot.manual-order.sidebar.right.tab.guide.list.v1` 下保存一份 root 级的已收起卡片 id 列表,每个 id 为 `kind:入口 id`,格式为 `{ "unpinned": string[] }`;该键名为以后的引导入口 list slot 预留。卡片由 kind 而不是提供方 id 标识,因此扩展接管某个 kind 且保持入口 id 时,Pin 选择仍然有效。kind 为 `files` 的卡片始终显示。收起只隐藏卡片,不关闭标签,也不改变标签类型注册或默认页。描述的显示密度按可见卡片数计算。未注册卡片的 id 保留在列表中;保存内容不是字符串数组时,所有卡片都显示。 +root 作用域的 `sidebar.right.tab.guide.actions` list 有注册项时,引导卡片之后显示**更多工具**卡片。它的菜单与卡片末端对齐,不窄于卡片,并随菜单行增宽,最大为 480px 与视口宽度减 24px 中的较小值。菜单行为该 list 的注册项。每个注册项接收 `entries`(按注册表顺序的卡片,含 `id`、`label` 与 `pinned`;不含 kind 为 `files` 的卡片)、在引导页位置打开卡片标签类型的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。引导页在内存中保存已收起卡片 id 列表,每个 id 为 `kind:入口 id`。插件重新加载时 Pin 选择重置,不读取旧 localStorage 值。默认更多菜单只提供插件浏览和创建;其他注册项仍可使用 Pin 回调。卡片由 kind 而不是提供方 id 标识,因此扩展接管某个 kind 且保持入口 id 时,Pin 选择仍然有效。kind 为 `files` 的卡片始终显示。收起只隐藏卡片,不关闭标签,也不改变标签类型注册或默认页。描述的显示密度按可见卡片数计算。未注册卡片的 id 保留在内存中,直到插件重新加载。 终端等需要独立实例的 tab 类型可声明 `multiple: true`。每次打开都会获得独立内容地址,因此移动和停靠会保留这些实例;普通页面仍在每个格内去重。 diff --git a/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx index 4d40f86e01..febe9f1954 100644 --- a/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx +++ b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx @@ -19,8 +19,7 @@ * open. */ import { useState } from 'react' -import clsx from 'clsx' -import { IconAppsAddOutlineRegular, Menu, ShortcutKeys } from '@deepseek-ai/dsh-client-ui-primitives' +import { Menu, ShortcutKeys } from '@deepseek-ai/dsh-client-ui-primitives' import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client' import type { ReactNode } from 'react' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' @@ -30,6 +29,7 @@ import type { import type { SidebarRightGuideActionsOwnerProps } from '../../contract/slots.ts' import type { SidebarRightGuideBox } from '../../tab-registry.ts' import { CompassGlyph, CubeGlyph } from './GuideTitle.tsx' +import { GuideMoreIcon } from './GuideMoreIcon.tsx' import { FIXED_GUIDE_KIND, guideEntryId } from './guide-pins.ts' import css from './GuideBody.module.css' @@ -39,7 +39,7 @@ export interface GuideInjected { readonly hooks: { readonly shortcuts: ObservableSnapshot readonly guideEntries: ObservableSnapshot - /** Unpinned `kind:id` card ids, persisted across reloads. */ + /** Unpinned `kind:id` card ids held in memory until plugin reload. */ readonly entryPins: ObservableSnapshot /** Whether `sidebar.right.tab.guide.actions` has registrations. */ readonly entryActions: ObservableSnapshot @@ -106,7 +106,7 @@ function GuideMoreMenu({ label, entries, onOpen, onPinnedChange, renderSlot }: G anchor={( )}> diff --git a/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideMoreIcon.tsx b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideMoreIcon.tsx new file mode 100644 index 0000000000..61f7913aa3 --- /dev/null +++ b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideMoreIcon.tsx @@ -0,0 +1,15 @@ +/** Designer-supplied artwork used only by the right-sidebar More tools card. */ + +/** + * Render the More tools glyph at its authored size and ink. + * @returns Decorative 22px More tools artwork. + */ +export function GuideMoreIcon() { + return +} diff --git a/packages/client/ui-sidebar-right/src/client/tabs/guide/guide-pins.ts b/packages/client/ui-sidebar-right/src/client/tabs/guide/guide-pins.ts index 3614a6778b..f43ee7503d 100644 --- a/packages/client/ui-sidebar-right/src/client/tabs/guide/guide-pins.ts +++ b/packages/client/ui-sidebar-right/src/client/tabs/guide/guide-pins.ts @@ -1,12 +1,10 @@ -/** Persisted pin choices for the guide's tool cards. */ +/** In-memory pin choices for the guide's tool cards. */ import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { SidebarRightGuideBox } from '../../tab-registry.ts' /** Tab kind whose card always shows and never enters the customization list. */ export const FIXED_GUIDE_KIND = 'files' -/** Root-level key reserved for the future guide list slot's per-list preferences. */ -const STORAGE_KEY = 'dsh.slot.manual-order.sidebar.right.tab.guide.list.v1' const NO_CARDS: readonly string[] = [] /** @@ -23,30 +21,23 @@ export interface GuidePins { /** Unpinned `kind:id` card ids; ids of cards that are not registered stay listed. */ readonly unpinned: ObservableSnapshot /** - * Show or hide one guide card; cards of the fixed kind are ignored. + * Pin or unpin one guide card; cards of the fixed kind are ignored. * @param id - `kind:id` card id. - * @param pinned - Whether the card shows on the guide. + * @param pinned - Whether the card is manually pinned. */ readonly setPinned: (id: string, pinned: boolean) => void } -/** Stored content is unchecked; anything but a string array leaves every card pinned. */ -function unpinnedCards(value: unknown): readonly string[] { - if (typeof value !== 'object' || value === null || !('unpinned' in value)) return NO_CARDS - const { unpinned } = value - return Array.isArray(unpinned) && unpinned.every((id): id is string => typeof id === 'string') ? unpinned : NO_CARDS -} - /** - * Create the persisted guide pin list; the snapshot keeps its reference until the list changes. - * @returns Validated unpinned card ids and the pin mutation. + * Create the in-memory guide pin list; the snapshot keeps its reference until the list changes. + * @returns Unpinned card ids and the pin mutation. */ export function createGuidePins(): GuidePins { - const store = createSnapshotStore({ unpinned: NO_CARDS }, { persist: { name: STORAGE_KEY } }) + const store = createSnapshotStore<{ readonly unpinned: readonly string[] }>({ unpinned: NO_CARDS }) return { - unpinned: { getSnapshot: () => unpinnedCards(store.getSnapshot()), subscribe: listener => store.subscribe(listener) }, + unpinned: { getSnapshot: () => store.getSnapshot().unpinned, subscribe: listener => store.subscribe(listener) }, setPinned: (id, pinned) => { - const current = unpinnedCards(store.getSnapshot()) + const current = store.getSnapshot().unpinned if (id.startsWith(`${FIXED_GUIDE_KIND}:`) || pinned !== current.includes(id)) return store.set({ unpinned: pinned ? current.filter(entry => entry !== id) : [...current, id] }) }, diff --git a/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx b/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx index c4dd7131f6..fb6cf422c3 100644 --- a/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx @@ -119,6 +119,12 @@ describe('GuideBody', () => { const guide = b.view.container.querySelector('[data-sidebar-right-guide]') expect(guide?.lastElementChild?.contains(more)).toBe(true) expect(more.getAttribute('aria-haspopup')).toBe('menu') + const glyph = more.querySelector('svg') + expect(glyph?.getAttribute('width')).toBe('22') + expect(glyph?.getAttribute('height')).toBe('22') + expect(glyph?.getAttribute('stroke')).toBe('#A6B2C7') + expect(glyph?.getAttribute('aria-hidden')).toBe('true') + expect(glyph?.querySelectorAll('path')).toHaveLength(4) fireEvent.click(more) expect(more.getAttribute('aria-expanded')).toBe('true') expect(b.actionsOwner().entries).toEqual([ diff --git a/packages/client/ui-sidebar-right/tests/guide-pins.client.spec.ts b/packages/client/ui-sidebar-right/tests/guide-pins.client.spec.ts index 1bc8994b94..5c775816fd 100644 --- a/packages/client/ui-sidebar-right/tests/guide-pins.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/guide-pins.client.spec.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -/** Guide card pins persist per `kind:id` and ignore every Files card. */ +/** Guide card pins stay in memory per `kind:id` and ignore every Files card. */ import { afterEach, describe, expect, it, vi } from 'vitest' import { createGuidePins, guideEntryId } from '../src/client/tabs/guide/guide-pins.ts' @@ -11,15 +11,15 @@ describe('guide card pins', () => { expect(guideEntryId({ kind: 'terminal', id: 'shell' })).toBe('terminal:shell') }) - it('persists unpinned card ids and restores them in a new instance', () => { + it('keeps unpinned card ids in memory without restoring them in a new instance', () => { const pins = createGuidePins() const initial = pins.unpinned.getSnapshot() expect(initial).toEqual([]) expect(pins.unpinned.getSnapshot()).toBe(initial) pins.setPinned('terminal:shell', false) pins.setPinned('browser:page', false) - expect(JSON.parse(localStorage.getItem(KEY) ?? 'null')).toEqual({ unpinned: ['terminal:shell', 'browser:page'] }) - expect(createGuidePins().unpinned.getSnapshot()).toEqual(['terminal:shell', 'browser:page']) + expect(localStorage.getItem(KEY)).toBeNull() + expect(createGuidePins().unpinned.getSnapshot()).toEqual([]) pins.setPinned('terminal:shell', true) expect(pins.unpinned.getSnapshot()).toEqual(['browser:page']) }) @@ -38,20 +38,14 @@ describe('guide card pins', () => { }) it('keeps ids of cards that are not registered', () => { - localStorage.setItem(KEY, JSON.stringify({ unpinned: ['removed:card'] })) const pins = createGuidePins() + pins.setPinned('removed:card', false) pins.setPinned('terminal:shell', false) expect(pins.unpinned.getSnapshot()).toEqual(['removed:card', 'terminal:shell']) }) - it.each([ - ['a non-array list', { unpinned: 'terminal:shell' }], - ['non-string ids', { unpinned: [false] }], - ['a missing list', {}], - ['null', null], - ['a number', 7], - ])('reads %s as no unpinned cards with a stable snapshot', (_name, stored) => { - localStorage.setItem(KEY, JSON.stringify(stored)) + it('ignores a legacy stored list instead of restoring it', () => { + localStorage.setItem(KEY, JSON.stringify({ unpinned: ['terminal:shell'] })) const pins = createGuidePins() const snapshot = pins.unpinned.getSnapshot() expect(snapshot).toEqual([]) diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index c1df759615..d10c5fa113 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/README.i18n.yaml @@ -21,8 +21,8 @@ en: 098f85b9287d3147 zh: b921c128ac37d2cd /deepseek-ai-dsh-client-ui-sidebar/use-this-package/global-panel-entries: - en: b109498d88367117 - zh: 6d407f265bacdb0d + en: e7f316023445511b + zh: 6271c8c011533877 /deepseek-ai-dsh-client-ui-sidebar/use-this-package/collapse-behavior: en: 20e744360b4be2cc zh: a90be7e8526bbb21 @@ -33,11 +33,11 @@ en: 9e7b31acee759f2b zh: 3a6beb63b23d26ae /deepseek-ai-dsh-client-ui-sidebar/use-this-package/click-analytics: - en: bb3f4a199ed49039 - zh: cc29413fd31fe2e7 + en: 8855ad013285a27e + zh: f19b3e75c38c5b0a /deepseek-ai-dsh-client-ui-sidebar/understand-the-implementation: - en: bc51c446d8b8e098 - zh: c0c7bbfb50541d33 + en: 39b5e3ca8ce8c5c2 + zh: 1d652b7554772dad /deepseek-ai-dsh-client-ui-sidebar/understand-the-implementation/slot-discipline: en: 6eefc5c45abe8a8f zh: 20b651e48a0ea903 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 27f22137f7..ce59c01d45 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -37,7 +37,7 @@ The expanded brand row renders `sidebar.brand.mark` and `sidebar.brand.name` as Plugins add an icon component to the root-scoped `sidebar.panellist` list with an `id`, optional `order`, and a string or locale-aware `label`. The same id addresses the component registered in the layout's root-scoped `main` keyed slot; selecting a missing main entry throws without changing the current selection. The label supplies plain visible text, the accessible name, and the collapsed tooltip. Expanded navigation rows have a 2px gap. Each row reads its own selected state through `usePanelInfo`; moving DOM focus to search or a directory picker does not change the displayed panel or its selected row. With no panel registrations, neither the list nor spacing for it is rendered. The shipped composition registers no example panel. -While the root-scoped `sidebar.panellist.actions` list has registrations, a **More** button styled as a panel row follows the rows inside the navigation landmark; the collapsed rail shows its label as a tooltip. It opens a menu whose rows are that list's registrations. Each receives `entries` (customizable panels in registration order with `id`, `label`, and `pinned`; Plugins is never listed), `onOpen(id)`, which selects the panel, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The sidebar keeps one root list of unpinned panel ids in browser `localStorage` under `dsh.slot.manual-order.sidebar.panellist.v1`, stored as `{ "unpinned": string[] }` and shared by every Session; the rows omit unpinned panels except the selected one, which keeps its row and `aria-current` until another panel is selected, and Plugins always shows. Unpinning hides only the row; the main panel, its registration, and the current selection stay unchanged. Ids of panels that are not registered stay stored, so a panel that registers again stays unpinned; stored content other than a string array shows every row. +While the root-scoped `sidebar.panellist.actions` list has registrations, a **More** button styled as a panel row follows the rows inside the navigation landmark; the collapsed rail shows its label as a tooltip. It opens a menu whose rows are that list's registrations. Each receives `entries` (customizable panels in registration order with `id`, `label`, `pinned`, and `active`; Plugins is never listed), `onOpen(id)`, which selects the panel, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The sidebar keeps one in-memory list of unpinned panel ids shared by every Session; Plugins always shows. The default More menu only offers plugin browsing and creation, without panel rows or pin controls. Pin callbacks remain available to other registrations, but choices reset when the plugin reloads and legacy localStorage values are ignored. Unpinning hides only the row; the main panel, its registration, and the current selection stay unchanged. Unregistered panel ids remain in the in-memory list until the plugin reloads. ### Collapse behavior @@ -64,7 +64,7 @@ On Desktop, the official navigation controls report `sidebar_menu_click` with `m - The expanded brand and the New Session button report `menu_name=new_session` with `position=sidebar`. While the Windows caption hosts the collapsed New Session button, that button reports `position=titlebar`; the macOS New Session control beside the traffic lights also reports `position=titlebar`. - The **Plugins** and **Automation tasks** rows report `menu_name=plugin` and `menu_name=cron` with `position=sidebar`. Other panel rows report nothing. - Each **More** button click reports `menu_name=more` with `position=sidebar`, whether it opens or closes the menu. -- A More menu row that opens Plugins or Automation tasks through `onOpen(id)` reports `menu_name=plugin` or `menu_name=cron` with `position=sidebar_more`. The report precedes the registration check, so a request for a panel that has unregistered still counts although nothing is selected. +- A third-party More menu row that calls `onOpen(id)` for Plugins or Automation tasks reports `menu_name=plugin` or `menu_name=cron` with `position=sidebar_more`. The shipped More menu only browses and creates plugins, so it never calls `onOpen`. The report precedes the registration check, so a request for a panel that has unregistered still counts although nothing is selected. ----- @@ -74,7 +74,7 @@ On Desktop, the official navigation controls report `sidebar_menu_click` with `m
Implementation internals — click to expand -The shell is pure composition: `SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared brand, the `sidebar.workspaces` and `sidebar.settings` child slots, and injected navigation callbacks. Panel entries and their optional titles use the same composition path. Panel metadata is derived from list registrations and locale changes; selection belongs to the layout store. The injected `trackEntryClick(attributes)` forwards to `ctx.get('productAnalytics')?.track('sidebar_menu_click', attributes)`, and `selectPanel` only selects, so each control supplies its own entry and position. +The shell is pure composition: `SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared brand, the `sidebar.workspaces` and `sidebar.settings` child slots, and injected navigation callbacks. The sidebar owns `sidebar.panellist` and renders each panel's registered icon directly. Panel metadata is derived from list registrations and locale changes; selection belongs to the layout store. The injected `trackEntryClick(attributes)` forwards to `ctx.get('productAnalytics')?.track('sidebar_menu_click', attributes)`, and `selectPanel` only selects, so each control supplies its own entry and position. ### Slot discipline diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index b4d636ad10..a5d6610fb9 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -37,7 +37,7 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将 插件在 root 作用域的 `sidebar.panellist` list 中注册图标组件,提供 `id`、可选 `order`,以及字符串或 locale-aware 的 `label`。同一个 id 寻址布局中 root 作用域 `main` keyed slot 的组件;选择不存在的主面板条目会抛错,并保留当前选中态。标签提供普通可见文字、无障碍名称和折叠提示。展开态导航行的间距为 2px。每一行通过 `usePanelInfo` 读取自己的选中态;DOM 焦点移到搜索框或目录选择器时,显示的面板及其列表项选中态不变。没有面板注册项时,列表及其间距均不渲染。产品随附的组合不注册示例面板。 -root 作用域的 `sidebar.panellist.actions` list 有注册项时,导航区域的面板行之后显示面板行样式的**更多**按钮;收起的窄栏以提示框显示其名称。它打开的菜单以该 list 的注册项为菜单行。每个注册项接收 `entries`(按注册顺序的可自定义面板,含 `id`、`label` 与 `pinned`;不含插件管理)、选中面板的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。侧边栏在浏览器 `localStorage` 的 `dsh.slot.manual-order.sidebar.panellist.v1` 下保存一份 root 级的已收起面板 id 列表,格式为 `{ "unpinned": string[] }`,所有会话共用;面板行不显示已收起的面板,但当前选中的面板保留其行和 `aria-current`,直到选中其他面板;插件管理入口始终显示。收起只隐藏导航行,主面板、其注册和当前选中态不变。未注册面板的 id 保留在列表中,因此重新注册的面板保持收起;保存内容不是字符串数组时,所有行都显示。 +root 作用域的 `sidebar.panellist.actions` list 有注册项时,导航区域的面板行之后显示面板行样式的**更多**按钮;收起的窄栏以提示框显示其名称。它打开的菜单以该 list 的注册项为菜单行。每个注册项接收 `entries`(按注册顺序的可自定义面板,含 `id`、`label`、`pinned` 与 `active`;不含插件管理)、选中面板的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。侧边栏在内存中保存所有会话共用的已收起面板 id 列表;插件管理入口始终显示。默认更多菜单只提供插件浏览和创建,不列出面板或 Pin 控件。其他注册项仍可调用 Pin 回调,但插件重新加载时选择重置,不读取旧 localStorage 值。收起只隐藏导航行,主面板、其注册和当前选中态不变。未注册面板的 id 保留在内存列表中,直到插件重新加载。 ### 折叠行为 @@ -64,7 +64,7 @@ root 作用域的 `sidebar.panellist.actions` list 有注册项时,导航区 - 展开态品牌与新建会话按钮上报 `menu_name=new_session`、`position=sidebar`。Windows 顶栏承载收起态新建会话按钮时,该按钮上报 `position=titlebar`;macOS 红绿灯旁的新建会话控件同样上报 `position=titlebar`。 - **插件**与**自动化任务**面板行分别上报 `menu_name=plugin` 与 `menu_name=cron`,`position=sidebar`。其他面板行不上报。 - 每次点击**更多**按钮都上报 `menu_name=more`、`position=sidebar`,无论打开还是关闭菜单。 -- 更多菜单中的行通过 `onOpen(id)` 打开插件或自动化任务时,上报 `menu_name=plugin` 或 `menu_name=cron`,`position=sidebar_more`。上报先于注册检查,因此所请求的面板已注销时仍会计数,但不会选中任何面板。 +- 第三方注册的更多菜单行通过 `onOpen(id)` 打开插件或自动化任务时,上报 `menu_name=plugin` 或 `menu_name=cron`,`position=sidebar_more`。随产品提供的更多菜单只浏览和创建插件,不会调用 `onOpen`。上报先于注册检查,因此所请求的面板已注销时仍会计数,但不会选中任何面板。 ----- @@ -74,7 +74,7 @@ root 作用域的 `sidebar.panellist.actions` list 有注册项时,导航区
实现细节——点击展开 -外壳是纯组合:`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 与 `useWorkspaces` 钩子、已声明的品牌、`sidebar.workspaces` 与 `sidebar.settings` 子 slot,以及注入的导航回调。面板入口及其可选标题使用相同的组合方式。面板元数据由列表注册和 locale 变化派生;选中态属于布局存储。注入的 `trackEntryClick(attributes)` 转发到 `ctx.get('productAnalytics')?.track('sidebar_menu_click', attributes)`;`selectPanel` 只负责选中,因此由各控件自行提供入口与位置。 +外壳是纯组合:`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 与 `useWorkspaces` 钩子、已声明的品牌、`sidebar.workspaces` 与 `sidebar.settings` 子 slot,以及注入的导航回调。侧边栏声明 `sidebar.panellist`,并直接渲染各面板注册的图标。面板元数据由列表注册和 locale 变化派生;选中态属于布局存储。注入的 `trackEntryClick(attributes)` 转发到 `ctx.get('productAnalytics')?.track('sidebar_menu_click', attributes)`;`selectPanel` 只负责选中,因此由各控件自行提供入口与位置。 ### slot 纪律 diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index 15c0cfa5b2..3f13691345 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -332,7 +332,7 @@ export function SidebarRoot({ {panels.length > 0 && (
+ +## Write actions + +The Turn Tail branch action reads the Session-scope `useAccess('session/fork')` Hook and disappears while the calling Peer's access snapshot excludes that endpoint; the view always receives the `forkAt` handler. A branch names its child only while the snapshot includes `session/rename` for the source Session; otherwise the child keeps the Host's title and opens once the Session list carries it. A branch the Host refuses states why, on the Session it was asked of, through the composer's notice channel, as the Host's own message with the stable code beside it. The Session tree's branch verb shares the refusal notice and the rule for naming and opening a branch. These answers never replace Host authorization. + ----- The Chat view detects selection and context-menu gestures on business nodes marked with `data-text-interaction-source`. Both selection endpoints must resolve to the same source container. Displayed user content and settled Assistant text or reasoning participate; tools, injected context, streaming or interrupted Assistant text, and Markdown controls do not. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 06d1d924b2..2a54199c2b 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -24,6 +24,7 @@ kind: "package-reference" - [轮次过程折叠](#turn-process-folding) - [分组渲染](#grouped-rendering) - [滚动归属](#scroll-ownership) +- [写操作](#write-actions) - [模型体验](#model-experience) - [已知限制与暂缓事项](#known-limitations-and-deferred-work) - [开发备注](#dev-note) @@ -41,13 +42,13 @@ kind: "package-reference" 原生消息图片使用独立的用户与输出位置。`conversation.chat.user.content` Factory 声明 `conversation.chat.user.images`,由 `UserContent` 渲染;Assistant 节点注册声明 `conversation.chat.message.images`,由 `AssistantMarkdown` 渲染。两者通过显式 props 接收图片来源、授权 `loadImage` 和布局参数。官方 `MessageImages` 条目调用共享的 root 作用域 `conversation.images.content` Factory。每个位置均可独立替换,无需复制画廊实现。 -Session 作用域的 `conversation.chat.user.content` Factory 只渲染用户内容区域:文本、额外内容块、按输入顺序排列的图片/文件附件及引用摘要。[`UserContentInput`](src/client/contract/slots.ts) 携带预处理的 `content`、变换 `facts`、显示 props 和显式 `loadImage`;引用动作由节点自己的 Injection 提供。正文调用共享 `conversation.user.text` Factory,图片调用自身拥有的图片 Slot。直接调用需要 Session 绑定,行包装层由调用方提供。 +Session 作用域的 `conversation.chat.user.content` Factory 只渲染用户内容区域:文本、额外内容块、按输入顺序排列的图片/文件附件及引用摘要。[`UserContentInput`](src/client/contract/slots.ts) 携带预处理的 `content`、变换 `facts`、显示 props 和显式 `loadImage`;引用动作由节点自己的 Injection 提供。正文调用共享 `conversation.user.text` Factory,图片调用自身拥有的图片 Slot。直接调用需要 Session 绑定,行包装层由调用方提供。引用渲染独立于 Session 的访问答案;撤回 `session/fork` 答案只移除分支操作,不替换内容或重置轮次开合状态。 `UserStyleBubble` 和[各来源包装层]() 保留用户行、key、pending/echo 标记、时间、复制控件及其他消息操作。内容 Factory 不读取来源专属模型或私有 Context,也不是全局用户内容替换入口。 独立的 Markdown 图片以内嵌预览显示,点击打开共享图片浮层;本地路径在消息落定后基于当前查看的工作区解析。图片文件链接保持点击打开侧栏,鼠标停留或键盘聚焦时显示缩略图,Esc 关闭缩略图。图片加载失败时保留本地化状态与图片说明;不执行重复图片过滤。 -设置 → 通用设置 → 网页链接默认打开方式控制普通点击 Chat HTTP(S) 链接时的目标:「应用内侧边栏」(默认)打开新的右侧 Sidebar Browser tab,「默认浏览器」打开外部标签页。该设置项位于快捷键之后,仅在 Sidebar Browser 可用时显示。若 Sidebar Browser 未注册,两种选择均使用外部浏览器;带修饰键的点击保留原生行为。已认证浏览器的 `ui-chat.linkOpening` 偏好在 Host 上持久化,设置无法持久化写入时仅在当前进程内生效。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。 +设置 → 通用设置 → 网页链接默认打开方式控制普通点击 Chat HTTP(S) 链接时的目标:「应用内侧边栏」(默认)打开新的右侧 Sidebar Browser tab,「默认浏览器」打开外部标签页。该设置项位于快捷键之后,仅在 Sidebar Browser 可用时显示。若 Sidebar Browser 未注册,两种选择均使用外部浏览器;带修饰键的点击保留原生行为。`ui-chat.linkOpening` 偏好在已认证浏览器的 Host 上持久化,设置无法持久化写入时仅在当前进程内生效。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。页面的标签类型注册表决定如何打开文件地址。只列出工作区路径而不提供文件内容的页面提供自己的标签反馈;Chat 只委派导航,不推断文件读取权限。 ## Chat 隐藏的行 @@ -174,6 +175,11 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且
+ +## 写操作 + +轮次末尾的分支操作读取会话作用域的 `useAccess('session/fork')` 钩子,调用方 Peer 的访问快照不含该端点时不显示;视图始终收到 `forkAt` 处理函数。只有当快照对源 Session 含 `session/rename` 时,分支才为子会话命名;否则子会话保留 Host 给的标题,并在会话列表出现它之后打开。Host 拒绝的一次分支会在它被请求的那个 Session 上说明原因(走 composer 的 notice 通道),内容为 Host 自己的消息并附上稳定错误码。会话树的分支动词共用这一拒绝提示,以及同一套命名与打开分支的规则。这些界面判断不替代 Host 授权。 + ----- Chat view 在标记了 `data-text-interaction-source` 的业务节点上检测选区和右键手势。选区两端必须定位到同一个来源容器。已展示的用户内容和已完成的 Assistant 文本或 reasoning 参与交互;Tool、注入上下文、流式或中断的 Assistant 文本以及 Markdown 控件不参与。 diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index c5ffa1e87c..a726fa8a32 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -161,6 +161,7 @@ export const zh = { 'message.stopped': '已停止', 'message.branch': '在新对话中分支', 'message.branchUnavailable': '仅可从已完成轮次的最后一条消息分支', + 'message.branchRefused': '未能完成分支:{reason}', 'message.retry.active': '正在重试模型请求', 'message.retry.cancelled': '模型请求重试已取消', 'message.retry.started': '已重试模型请求', @@ -363,6 +364,7 @@ export const en = { 'message.stopped': 'Stopped', 'message.branch': 'Branch into a new conversation', 'message.branchUnavailable': 'Available only on the last message of a completed turn', + 'message.branchRefused': 'Could not complete the branch: {reason}', 'message.retry.active': 'Retrying model request', 'message.retry.cancelled': 'Model request retry cancelled', 'message.retry.started': 'Retried model request', diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 557b9e2a04..7bf84793b1 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -69,9 +69,12 @@ async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true, openTab: vi.fn<(kind: string, options?: unknown) => void>(), } runtime.ctx.provide('sidebarRight', sidebarRight as never) + // Pages open files through the right Sidebar's tab-type registry, whose own claim decides whether + // an address opens at all; the spy records whether this call site ever asks it. const browserAvailable = createSnapshotStore(true) const sidebarRightTabs = { get: vi.fn<(kind: string) => object | undefined>(() => browserAvailable.getSnapshot() ? {} : undefined), + candidates: vi.fn<(address: string) => readonly object[]>(() => []), subscribe: (listener: () => void) => browserAvailable.subscribe(listener), register: vi.fn(() => () => {}), } @@ -82,13 +85,15 @@ async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true, ) runtime.remote.provideNamespaces({ session: { openWorkspacePath } }) const openSession = vi.fn<(id: SessionId) => void>() - runtime.ctx.provide('uiWorkspace', { + const uiWorkspace = { openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { beforeOpen(ROOT) openSession(ROOT) }), openSession, - } as never) + openListedBranch: vi.fn((_childId: SessionId) => {}), + } + runtime.ctx.provide('uiWorkspace', uiWorkspace as never) const session = sessionFakeFor() await runtime.sessions.add({ id: ROOT, @@ -135,6 +140,7 @@ async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true, }, runtime, chat, chatSettings, browserAvailable, layout, openWorkspacePath, sidebarRight, sidebarRightTabs, session, chatViewApi, chatFlowApi, rootReference, openSession, + uiWorkspace, } } @@ -288,6 +294,59 @@ describe('Chat inject API', () => { await b.runtime.dispose() }) + it('states why a refused branch failed, on the Session it was asked of', async () => { + /** The notice the addressed Session's composer currently carries, as the page would show it. */ + const noticeOf = (sessionId: SessionId): string | undefined => { + const scope = b.runtime.sessions.scope(sessionId) + if (scope === undefined) return undefined + const input = scope.get('conversation')?.input.for(scope) + return input?.notices.getSnapshot()?.text + } + const b = await bench() + const { injected } = b.chatViewApi(b.rootReference) + // The Host's own message reaches the reader with the stable code beside it, and the Session it was + // asked of is the one that shows it. + vi.spyOn(b.runtime.sessions, 'fork').mockRejectedValueOnce({ + rpcError: { code: 'boundary-not-committed', message: 'boundary is not committed' }, + }) + injected.forkAt(18) + await vi.waitFor(() => { + expect(noticeOf(ROOT)).toBe( + '未能完成分支:boundary is not committed (boundary-not-committed)', + ) + }) + // A rejection that named no code keeps its own message and gains no placeholder. + vi.spyOn(b.runtime.sessions, 'fork').mockRejectedValueOnce(new Error('fork child rename failed')) + injected.forkAt(19) + await vi.waitFor(() => { + expect(noticeOf(ROOT)).toBe('未能完成分支:fork child rename failed') + }) + await b.runtime.dispose() + }) + + it('leaves a branch unnamed and opens it once listed while access excludes renaming the Session', async () => { + const b = await bench() + const source = vi.spyOn(b.runtime.ctx.remoteAccess, 'source') + source.mockImplementation((_kind, _id, endpoint) => createSnapshotStore(endpoint !== 'session/rename')) + const { injected } = b.chatViewApi(b.rootReference) + injected.forkAt(17) + // Naming a branch renames it, which this page may not do: asking for the rename would fail the + // fork after the Host already made the child, so the page forks an unnamed branch. + await vi.waitFor(() => { + expect(b.runtime.sessions.calls).toContainEqual({ + method: 'fork', args: [{ sessionId: ROOT, atSeq: 17, increaseTitle: false, onCreated: expect.any(Function) as (childId: SessionId) => void }], + }) + }) + // The child's own list item is what says the branch is this page's own, so the page asks the + // Workspace navigation to open it then — that service waits for the item rather than assembling + // a view of a child the page cannot yet describe. + expect(b.uiWorkspace.openListedBranch).toHaveBeenCalledWith(ROOT) + expect(b.runtime.sessions.calls).not.toContainEqual({ method: 'open', args: [ROOT] }) + expect(source).toHaveBeenCalledWith('session', ROOT, 'session/rename') + source.mockRestore() + await b.runtime.dispose() + }) + it('addresses file paths under the Session\'s scope and opens them in the right Sidebar', async () => { const b = await bench() const { injected } = b.chatViewApi(b.rootReference) @@ -308,6 +367,10 @@ describe('Chat inject API', () => { // A line travels as the `file` type's navigation parameter, not in the address. await injected.openFile('src/a.ts', { line: 7 }) expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1/src/a.ts', { params: { line: 7 } }) + // Which type claims the address is the Sidebar registry's decision: the call site hands the + // address over without asking whether this page carries a type that would open it. A page with + // none — a reader's share — is answered by its own registered type, or by the Sidebar's refusal. + expect(b.sidebarRightTabs.candidates).not.toHaveBeenCalled() await b.runtime.dispose() }) @@ -509,6 +572,7 @@ describe('Chat inject API', () => { const flow = b.chatFlowApi(b.rootReference) expect(flow.instance).toBe(b.chatViewApi(b.rootReference).instance) + expect(flow.injected.hooks.presentation).toBe(injected.hooks.presentation) expect(flow.injected.keyedHooks.chatNode('missing')).toBe(injected.keyedHooks.chatNode('missing')) expect(flow.injected.keyedHooks.chatNodeProcess('missing')).toBe(injected.keyedHooks.chatNodeProcess('missing')) expect(flow.injected.keyedHooks.chatNodeBottom('missing')).toBe(injected.keyedHooks.chatNodeBottom('missing')) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index cf2516d005..8c62bccf63 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -1926,6 +1926,43 @@ describe('ChatView', () => { expect(h.forkAt).toHaveBeenCalledWith(3) })) + it('keeps next-input folding independent from a live session/fork answer in the same flow', () => { + const h = makeCompletionHarness('detailed', 'next-input') + const fork = createSnapshotStore(true) + const useFork = bindSnapshotSelector(fork) + // Chat Node renderers read the harness's standard props object, so the override goes there. + Object.assign(h.props, { + useAccess: (endpoint: string) => { + const allowed = useFork(value => value) + return endpoint === 'session/fork' ? allowed : true + }, + }) + const view = render() + const root = view.container.querySelector('[data-chat-group-key][data-chat-turn="2"]')! + act(() => { h.complete() }) + const control = view.container.querySelector('[data-turn-process="2"]')! + expect(control.getAttribute('aria-expanded')).toBe('true') + expect(root.closest('[hidden]')).toBeNull() + expect(view.getAllByRole('button', { name: '在新对话中分支' })).toHaveLength(2) + + act(() => { fork.set(false) }) + expect(view.queryAllByRole('button', { name: '在新对话中分支' })).toHaveLength(0) + expect(view.container.querySelector('[data-chat-group-key][data-chat-turn="2"]')).toBe(root) + expect(control.getAttribute('aria-expanded')).toBe('true') + expect(root.closest('[hidden]')).toBeNull() + + act(() => { h.sendNext() }) + expect(view.getByText('next question').closest('[data-submission-echo]')).not.toBeNull() + expect(control.getAttribute('aria-expanded')).toBe('false') + expect(root.hasAttribute('hidden')).toBe(true) + act(() => { fork.set(true) }) + expect(view.getAllByRole('button', { name: '在新对话中分支' })).toHaveLength(2) + expect(view.container.querySelector('[data-chat-group-key][data-chat-turn="2"]')).toBe(root) + expect(control.getAttribute('aria-expanded')).toBe('false') + expect(root.hasAttribute('hidden')).toBe(true) + expect(view.getByText('current answer').closest('[hidden]')).toBeNull() + }) + it('keeps pending file mentions non-interactive until admission enables the reference injector', () => { const pending: InboxState['next-step'][number] = { id: MessageId('pending-file-mention'), role: 'user', source: { kind: 'user' }, diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 4bcc57e1cd..b75dc1dc03 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -86,6 +86,8 @@ While the root-scoped `conversation.session.header.views.actions` list has regis The composer registers the File command action and owns its label, availability, and native file-dialog callback. Menu availability and invocation both consult the mounted composer's current attachment-intake policy. Unmounting or locking the composer disables that action; disposing the plugin removes its registration. The callback binding stays inside the input module. +The composer reads its Session's access answers through the Session-scope `useAccess` Hook: Send disappears while the access snapshot excludes `session/prompt`, Stop while it excludes `session/cancel`, and file intake follows `fileUploads/upload`. A composer without a Session has no answer and keeps its ordinary controls. Stop and file intake recheck the live answer when invoked. These UI answers never replace Host authorization. + `SessionInputShell` owns one Lexical editor per Session through its private [DraftEditorRuntime](src/client/input/editor/runtime.ts), while retaining submission, attachment selection, and recovery decisions. [DraftEditor](src/client/input/editor/DraftEditor.tsx) renders the borrowed editor; InputBar retains its Hooks and refs and installs DOM behavior through [view-binding](src/client/input/editor/view-binding.ts). Editor-facing types live in [draft-editor.ts](src/client/contract/draft-editor.ts), with shared input and submission types in [input.ts](src/client/contract/input.ts). Background editor updates retain the draft selection without changing the document selection or reclaiming focus from another control; explicit Composer focus restores its selection. This separation does not support simultaneous editable roots for one Session; [the two-stage isolation proposal](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.md) defines the remaining work. Drafts retain plain text and file, folder, and Session reference chips across Session switching and reload. Public input methods accept text; internal restoration and Workspace transfer preserve structured references. The input model imports saved content before its first published read; React only mounts that editor and binds the existing Conversation store writer. The original storage key accepts legacy strings and writes semantic snapshots without runtime node IDs. A pending skill catalog does not block editing: slash names remain ordinary text until a matching catalog entry arrives. Arrival rematches the current text without changing chips, selection, or Undo history. Browser-owned attachment File objects are not persisted by this mechanism. The [draft initialization decision](../../../.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.md) defines ownership and restoration. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 57c499aaf8..afc088144f 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -86,6 +86,8 @@ root 作用域的 `conversation.session.header.views.actions` list 有注册项 输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。 +输入框通过会话作用域的 `useAccess` 钩子读取所在 Session 的访问答案:访问快照不含 `session/prompt` 时隐藏发送,不含 `session/cancel` 时隐藏停止,附件接收跟随 `fileUploads/upload`。没有 Session 的输入框尚无答案,保留普通控件。停止与附件接收在调用时重新读取当前答案。这些界面判断不替代 Host 授权。 + `SessionInputShell` 通过私有 [DraftEditorRuntime](src/client/input/editor/runtime.ts) 为每个 Session 持有一个 Lexical editor,同时保留提交、附件选择和恢复决策。[DraftEditor](src/client/input/editor/DraftEditor.tsx) 呈现借用的 editor;InputBar 保留钩子与 refs,并通过 [view-binding](src/client/input/editor/view-binding.ts) 安装 DOM 行为。编辑器类型位于 [draft-editor.ts](src/client/contract/draft-editor.ts),共享输入和提交类型位于 [input.ts](src/client/contract/input.ts)。编辑器后台更新会保留草稿选区,不修改文档选区或从其他控件夺回焦点;显式聚焦 Composer 时恢复其选区。这一拆分不支持同一 Session 同时挂载多个可编辑 root;[两阶段隔离提案](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.zh.md) 定义剩余工作。 草稿中的普通文字及文件、目录、Session 引用胶囊在切换 Session 和刷新后保留。公开输入方法接受文本;内部恢复和工作区携带保留结构化引用。输入模型在首次对外读取前导入保存内容;React 只挂载同一编辑器并绑定现有 Conversation store 写入回调。原存储 key 兼容旧字符串,新写入保存不含运行时节点 ID 的语义快照。等待 skill 目录不会阻塞编辑:斜杠名称保持普通文字,直到匹配的目录项到达。目录到达只重匹配当前文字,不改变胶囊、选区或 Undo 历史。浏览器持有的附件 File 对象不由这一机制持久化。[草稿初始化决策](../../../.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md)规定所有权与恢复流程。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 6a004c8740..bf038fc239 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -58,7 +58,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Services required by the Conversation plugin. */ export const inject = [ - 'slots', 'sessions', 'fileUpload', 'uiSession', 'uiWorkspace', 'locale', 'configForms', + 'slots', 'sessions', 'fileUpload', 'uiSession', 'uiWorkspace', 'locale', 'configForms', 'remoteAccess', ] /** Conversation runtime configuration. */ @@ -96,6 +96,10 @@ const ABSENT_FILE_UPLOADS = { getSnapshot: () => EMPTY_FILE_UPLOADS, subscribe: () => () => {}, } +const ABSENT_FILE_INTAKE = { + getSnapshot: () => false, + subscribe: () => () => {}, +} /** * Browser-shell bridge reporting the harness-host path of a picked file. The @@ -158,8 +162,10 @@ export function apply(ctx: Context, config: Config = Config({})): void { const slots = ctx.slots // Schemastery's field default is materialized before Cordis calls apply. const maxConcurrentFileUploads = config.maxConcurrentFileUploads as number - const workspaceNavigation = ctx.get('uiWorkspace') as unknown as WorkspaceNavigation + const workspaceNavigation = ctx.get('uiWorkspace') as WorkspaceNavigation const uiConversation = new UiConversation(ctx, sessions) + // File intake is its own access question: a page may prompt a Session whose files it may not upload. + const fileIntakeOf = (id: SessionId) => ctx.remoteAccess.source('session', id, 'fileUploads/upload') uiConversation.userText.register(sessionReferenceRule) uiConversation.userText.register(collapseWhitespaceRule) uiConversation.userText.register(trimWhitespaceRule) @@ -237,7 +243,16 @@ export function apply(ctx: Context, config: Config = Config({})): void { if (trackedBindings.has(binding)) return trackedBindings.add(binding) bindings.add(binding) - binding.ctx.effect(() => () => { bindings.delete(binding) }, 'ui-conversation: active Provider binding') + ctx.effect(() => binding.ctx.effect(() => { + // Controller withdrawal precedes asynchronous Session-scope disposal. + const disposeRetention = sessions.retainInfo(binding.sessionId).subscribe(() => { + if (sessions.binding(binding.sessionId) !== binding) bindings.delete(binding) + }) + return () => { + disposeRetention() + bindings.delete(binding) + } + }, 'ui-conversation: active Provider binding'), 'ui-conversation: View binding ownership') } const refreshViews = (): void => { const current = conversationViews.getSnapshot() @@ -258,6 +273,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { disposeDeveloperTools() disposeLocale() disposeViews() + bindings.clear() } }, 'ui-conversation: View selection') @@ -484,6 +500,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { stopShortcut, busyEnter: submissionPolicy.busyEnter, fileUploads: ABSENT_FILE_UPLOADS, + fileIntake: ABSENT_FILE_INTAKE, notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON, menuLauncher: ABSENT_MENU_LAUNCHER, @@ -494,9 +511,12 @@ export function apply(ctx: Context, config: Config = Config({})): void { const shell = inputHub.shell(sessionId) const inputTriggers = inputHub.inputTriggers(sessionId) const bridge = hostPathBridge() + const fileIntake = fileIntakeOf(sessionId) + const cancelAccess = ctx.remoteAccess.source('session', sessionId, 'session/cancel') return { keyboard: shell, addFiles: (files, directories = new Set()) => { + if (!fileIntake.getSnapshot()) return t('attachment.dropBlocked') if (sessions.binding(sessionId) === undefined) return t('file.sessionUnavailable') if (shell.snapshot.phase === 'adjudicating' || shell.snapshot.phase === 'submitting') { return t('attachment.dropBlocked') @@ -557,10 +577,16 @@ export function apply(ctx: Context, config: Config = Config({})): void { span: { ...selection, draftRev: snapshot.draftRev }, }) }, - stop: () => { stop(sessionId) }, + // The bar hides Stop while the access snapshot excludes `session/cancel`; the handler re-reads + // the live answer, so a click the page had already rendered never reaches a withdrawn endpoint. + stop: () => { + if (!cancelAccess.getSnapshot()) return + stop(sessionId) + }, hooks: { stopShortcut, busyEnter: submissionPolicy.busyEnter, + fileIntake, fileUploads: conversation.fileUploads, notices: shell.notices, lexicon: shell.lexicon, diff --git a/packages/client/ui-conversation/src/client/branch.ts b/packages/client/ui-conversation/src/client/branch.ts new file mode 100644 index 0000000000..24c000553c --- /dev/null +++ b/packages/client/ui-conversation/src/client/branch.ts @@ -0,0 +1,72 @@ +/** + * How this package states one refused branch. + * + * The branch action on the conversation and the verb on the sidebar's Session row both reach this + * through the `uiConversation` service, so a reader's branch states the same reason whichever surface + * asked for it. + * + * @module @deepseek-ai/dsh-client-ui-conversation/src/client/branch + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +/** Stable code and message one fork rejection carries. */ +export interface ForkRefusal { + /** Code the Host refused with, or {@link UNNAMED_REFUSAL} when the rejection named none. */ + readonly code: string + /** The Host's own message, empty when the rejection carried none. */ + readonly message: string +} + +/** Code stand-in for a rejection that named none of its own. */ +const UNNAMED_REFUSAL = 'unknown' + +/** + * Read the stable code and message one fork rejection carries. + * @param error - Rejection the fork reported. + * @returns The code the Host refused with, and its own message when it named one. + */ +export function readForkRefusal(error: unknown): ForkRefusal { + const wrapped = error as { + readonly code?: unknown + readonly message?: unknown + readonly rpcError?: { readonly code?: unknown; readonly message?: unknown } + } + const code = typeof wrapped.rpcError?.code === 'string' && wrapped.rpcError.code !== '' + ? wrapped.rpcError.code + : typeof wrapped.code === 'string' && wrapped.code !== '' ? wrapped.code : UNNAMED_REFUSAL + const message = typeof wrapped.rpcError?.message === 'string' && wrapped.rpcError.message !== '' + ? wrapped.rpcError.message + : typeof wrapped.message === 'string' ? wrapped.message : '' + return { code, message } +} + +/** + * State why one branch was refused, on the Session it was asked of. + * + * The reason is the Host's own message with the stable code beside it. A failed branch leaves the Session + * unchanged; this notice is the only trace it leaves. + * @param ctx - Client root Context. + * @param sessionId - Session the branch was asked of. + * @param error - Rejection the fork reported. + * @param message - The caller's copy for one stated reason. + */ +export function noteBranchRefusal( + ctx: Context, + sessionId: SessionId, + error: unknown, + message: (reason: string) => string, +): void { + const refusal = readForkRefusal(error) + const scope = ctx.sessions.scope(sessionId) + if (scope === undefined) return + const conversation = scope.get('conversation') + if (conversation === undefined) return + // The Host's own words, with the stable code beside them when it named one; a rejection that named + // neither stays its message alone rather than gaining a placeholder code. + const reason = refusal.message === '' + ? refusal.code + : refusal.code === UNNAMED_REFUSAL ? refusal.message : `${refusal.message} (${refusal.code})` + conversation.input.for(scope).notify('error', message(reason)) +} diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index a47e044b6c..947054f75e 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -216,6 +216,8 @@ export interface SessionInput extends InputTarget { focus(): void /** Input state store (InputZone currency + decorations read here). */ readonly state: SnapshotStore + /** Latest notice this composer carries (null after clear), which the addressed bar renders. */ + readonly notices: SnapshotStore } /** Session-addressed access to the per-session input facade. */ diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index f8d86187d3..4d9df4900d 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -528,6 +528,8 @@ export interface ComposerBarInjected { toggleCommandMenu: ((selection: EditSelection) => void) | undefined stop: (() => void) | undefined hooks: { + /** Live permission for file intake, independently of text prompting. */ + fileIntake: ObservableSnapshot /** Readable sequence while the fixed Stop command is registered. */ stopShortcut: ObservableSnapshot /** diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts index 9b3ed5f5b8..64ea68b0c0 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembly.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts @@ -23,6 +23,7 @@ import { ConversationNodeAssembler } from './assembler.ts' import { ConversationEventRegistry } from './event-registry.ts' import { HistoricalImageCache } from './historical-images.ts' import { ConversationViewRegistry } from './view-registry.ts' +import { noteBranchRefusal } from '../branch.ts' import { ConversationGroupRegistry } from './group-registry.ts' import { ConversationInteractionRegistry } from './interaction-registry.ts' import { UserTextRegistry } from './user-text-registry.ts' @@ -236,6 +237,20 @@ export class UiConversation extends Service { }, 'ui-conversation assembly') } + /** + * State why one branch was refused, on the Session it was asked of. + * + * The reason comes from the page's own vocabulary for the code when it has one, and otherwise from + * the Host's own message with the stable code beside it. A failed branch leaves the Session + * unchanged; this notice is the only trace it leaves. + * @param sessionId - Session the branch was asked of. + * @param error - Rejection the fork reported. + * @param message - The caller's copy for one stated reason. + */ + noticeBranchRefusal(sessionId: SessionId, error: unknown, message: (reason: string) => string): void { + noteBranchRefusal(this.ctx, sessionId, error, message) + } + /** * Resolve the Conversation binding for one Controller binding or Session id. * @param source - Session binding or identity. diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index d4f06accc5..844acb801b 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -104,6 +104,7 @@ export type { } from './contract/input.ts' export type { ArbitrateKey, ArbitrateOutcome, DraftInput, DraftQuote, DraftReference, DraftSnapshot, ReferenceInsert, TokenSpan } from './contract/draft-editor.ts' export type { ComposerBlock, ComposerBlocks } from './contract/composer-blocks.ts' +export type { ForkRefusal } from './branch.ts' declare module '@deepseek-ai/cordis' { interface Context { diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 6ab4c5b4a9..32758bc6f8 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -213,7 +213,8 @@ export class InputHub implements SessionInputResolver { */ canPickFiles(id: SessionId): boolean { const binding = this.sessions().binding(id) - return binding !== undefined && this.shells.get(binding)?.canPickFiles() === true + return this.rootCtx.remoteAccess.source('session', id, 'fileUploads/upload').getSnapshot() + && binding !== undefined && this.shells.get(binding)?.canPickFiles() === true } /** @@ -222,7 +223,7 @@ export class InputHub implements SessionInputResolver { */ pickFiles(id: SessionId): void { const binding = this.sessions().binding(id) - if (binding !== undefined) this.shells.get(binding)?.pickFiles() + if (this.canPickFiles(id) && binding !== undefined) this.shells.get(binding)?.pickFiles() } /** diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 80d04e9b80..27d305c0e5 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -48,14 +48,21 @@ export const InputBar = memo(function InputBar({ retryFileUpload, toggleCommandMenu, stop, t, renderSlot, useBusyEnter, useFileUploads, useNotices, useLexicon, useMenuLauncher, useStopShortcut, + useFileIntake, useAccess, useProjection, sessionId, variant, disabled: inert = false, blocked, workspacePickerOpen = false, onRequestWorkspace, placeholder, accessory, }: InputBarProps) { const input = useInput(s => s) const notice = useNotices(s => s) + const acceptsFiles = useFileIntake(allowed => allowed) const busyEnter = useBusyEnter(s => s) const stopKeys = useStopShortcut(keys => keys) + // A page whose access snapshot excludes prompting or stopping this Session is offered no Send or + // no Stop rather than a disabled one; without a Session neither question has an answer yet. + const promptDenied = useAccess('session/prompt') === false + const cancelDenied = useAccess('session/cancel') === false + const stopAction = cancelDenied ? undefined : stop void useLexicon // hook seat stays bound by the inject compartment; text-ref decoration rides the shell's editor transforms const commandMenuOpen = useMenuLauncher(source => source === 'command') const [activity, setActivity] = useState(false) @@ -214,7 +221,8 @@ export const InputBar = memo(function InputBar({ // The host enforces the same image limits at submit for callers that bypass // this composer. const intakeFiles = useCallback((files: readonly File[], directories?: ReadonlySet): void => { - if (locked || machineBusy || (subagent !== null && !continuable) || addFiles === undefined || files.length === 0) return + if (!acceptsFiles || locked || machineBusy || (subagent !== null && !continuable) + || addFiles === undefined || files.length === 0) return const rejected = ((): string | null => { // Child prompts support images, but not generic file receipts or the // Desktop path-reference fallback. Refuse mixed batches before intake. @@ -240,9 +248,9 @@ export const InputBar = memo(function InputBar({ return addFiles(files, directories) })() if (rejected !== null) showToast(rejected) - }, [locked, machineBusy, subagent, continuable, addFiles, attachments, imageLimits, showToast, t]) + }, [acceptsFiles, locked, machineBusy, subagent, continuable, addFiles, attachments, imageLimits, showToast, t]) - const canAcceptDrop = (subagent === null || continuable) && !locked && !machineBusy && addFiles !== undefined + const canAcceptDrop = acceptsFiles && (subagent === null || continuable) && !locked && !machineBusy && addFiles !== undefined const fileInputRef = useRef(null) const onPickFiles = (e: ChangeEvent): void => { @@ -331,7 +339,7 @@ export const InputBar = memo(function InputBar({ // exposes Stop independently. const primaryStops = running && subagent === null && (empty || blocked !== undefined) // Disabled native buttons may omit mouseleave; their tooltip must close from state. - const primaryDisabled = primaryStops ? stop === undefined : empty || disabled || machineBusy || uploadsPending + const primaryDisabled = primaryStops ? stopAction === undefined : empty || disabled || machineBusy || uploadsPending const interruptible = running && continuable const primarySubmitMode = resolveSubmitMode(busyEnter, running, 'enter', steeringAvailable) const plainMessageDraft = !empty && input?.phase === 'plain' && !draft.trimStart().startsWith('/') @@ -342,7 +350,7 @@ export const InputBar = memo(function InputBar({ : t('input.send') const onPrimary = (): void => { if (primaryStops) { - stop?.() + stopAction?.() return } if (keyboard === undefined) return // absent machine: the button is disabled @@ -467,7 +475,7 @@ export const InputBar = memo(function InputBar({ ref={fileInputRef} type="file" multiple - disabled={subagent !== null} + disabled={!canAcceptDrop || subagent !== null} hidden onChange={onPickFiles} /> @@ -489,15 +497,15 @@ export const InputBar = memo(function InputBar({ {input === undefined || sessionId === undefined ? null :
{renderSlot('conversation.input.activity', { locked, onActiveChange: setActivity })}
} - {interruptible && ( - + {interruptible && !cancelDenied && ( + )} - - - + {(primaryStops ? cancelDenied : promptDenied) ? null : ( + + + + )}
diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 16caa7db32..6a3ce79d82 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -167,6 +167,38 @@ describe('Conversation inject API', () => { await vi.waitFor(() => { expect(document.querySelector('[data-quote-preview-text]')?.textContent).toBe('@literal /review') }) expect(document.querySelector('[data-slot-error]')).toBeNull() expect(errors).not.toHaveBeenCalled() + it('refuses every File intake while the access snapshot excludes uploads but allows prompting', async () => { + const b = await bench() + onTestFinished(() => b.runtime.dispose()) + const denied = createSnapshotStore(false) + const source = vi.spyOn(b.runtime.ctx.remoteAccess, 'source') + onTestFinished(() => { source.mockRestore() }) + source.mockImplementation((kind, id, endpoint) => { + expect([kind, id]).toEqual(['session', ROOT]) + return endpoint === 'fileUploads/upload' ? denied : createSnapshotStore(true) + }) + const composer = b.composerApi(ROOT) + expect(composer.hooks.fileIntake).toBe(denied) + for (const file of [new File(['note'], 'note.txt'), new File(['image'], 'shot.png', { type: 'image/png' })]) { + expect(composer.addFiles?.([file])).toBe('当前无法添加文件或图片') + } + expect(b.rootUpload).not.toHaveBeenCalled() + // The File command reads the same answer: a bound picker stays closed. + const contributions = new Map() + b.runtime.ctx.provide('commandUi', { + register: (contribution: CommandContribution) => { + contributions.set(contribution.name, contribution) + return () => { contributions.delete(contribution.name) } + }, + } satisfies Pick) + await vi.waitFor(() => { expect(contributions.has('file')).toBe(true) }) + const open = vi.fn() + composer.keyboard!.bindFilePicker({ open, available: () => true }) + const file = contributions.get('file')! + expect(file.available({ sessionId: ROOT })).toBe(false) + if (file.ui.kind !== 'action') throw new Error('File must be an action') + file.ui.run({ sessionId: ROOT }) + expect(open).not.toHaveBeenCalled() }) it('owns the File action, reads its mounted composer availability, and unregisters on disposal', async () => { @@ -403,6 +435,23 @@ describe('Conversation inject API', () => { await b.runtime.dispose() }) + it('never stops a Session after its access snapshot withdraws session/cancel', async () => { + const b = await bench() + const cancel = createSnapshotStore(true) + const source = vi.spyOn(b.runtime.ctx.remoteAccess, 'source') + onTestFinished(() => { source.mockRestore() }) + source.mockImplementation((_kind, _id, endpoint) => endpoint === 'session/cancel' ? cancel : createSnapshotStore(true)) + const composer = b.composerApi(ROOT) + composer.stop!() + await vi.waitFor(() => { expect(b.sessionFake.cancel).toHaveBeenCalledOnce() }) + // Withdrawn while the view is open: the same handler re-reads the live answer and the cancel + // never reaches the Session. + cancel.set(false) + composer.stop!() + expect(b.sessionFake.cancel).toHaveBeenCalledOnce() + await b.runtime.dispose() + }) + it('releases a draft attachment only after the input shell accepts its removal', async () => { const b = await bench() const composer = b.composerApi(ROOT) @@ -604,6 +653,56 @@ describe('Conversation inject API', () => { await b.runtime.dispose() }) + it('releases View refresh ownership before a withdrawn Session or plugin can receive locale changes', async () => { + const b = await bench() + onTestFinished(() => b.runtime.dispose()) + const removeView = b.slots.register( + { name: 'conversation.view', id: 'chat', label: 'Chat' }, + (() => null) as never, + ) + onTestFinished(removeView) + const binding = b.rootReference.binding + b.runtime.ctx.uiSession.bindingSource(b.rootReference) + const resolve = vi.spyOn(b.runtime.ctx.uiConversation, 'binding') + const errors = vi.spyOn(console, 'error') + onTestFinished(() => { resolve.mockRestore(); errors.mockRestore() }) + resolve.mockClear() + + b.rootReference.release() + expect(b.runtime.sessions.binding(ROOT)).toBeUndefined() + b.runtime.ctx.locale.setLocale('en') + expect(resolve).not.toHaveBeenCalled() + expect(errors).not.toHaveBeenCalled() + await binding.ctx.fiber.dispose() + await b.feature.dispose() + b.runtime.ctx.locale.setLocale('zh') + expect(resolve).not.toHaveBeenCalled() + expect(errors).not.toHaveBeenCalled() + }) + + it('detaches View refresh when Conversation unloads before its retained Session', async () => { + const b = await bench() + onTestFinished(() => b.runtime.dispose()) + const removeView = b.slots.register( + { name: 'conversation.view', id: 'chat', label: 'Chat' }, + (() => null) as never, + ) + onTestFinished(removeView) + b.runtime.ctx.uiSession.bindingSource(b.rootReference) + const resolve = vi.spyOn(b.runtime.ctx.uiConversation, 'binding') + const errors = vi.spyOn(console, 'error') + onTestFinished(() => { resolve.mockRestore(); errors.mockRestore() }) + + await b.feature.dispose() + expect(b.runtime.sessions.binding(ROOT)).toBe(b.rootReference.binding) + resolve.mockClear() + b.runtime.ctx.locale.setLocale('en') + b.rootReference.release() + b.runtime.ctx.locale.setLocale('zh') + expect(resolve).not.toHaveBeenCalled() + expect(errors).not.toHaveBeenCalled() + }) + it('projects the dynamic View registration ledger', async () => { const b = await bench() const source = b.viewSource(ROOT) diff --git a/packages/client/ui-conversation/tests/branch.client.spec.ts b/packages/client/ui-conversation/tests/branch.client.spec.ts new file mode 100644 index 0000000000..5802852942 --- /dev/null +++ b/packages/client/ui-conversation/tests/branch.client.spec.ts @@ -0,0 +1,73 @@ +/** What one refused branch states, on the Session it was asked of. */ + +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, vi } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { noteBranchRefusal, readForkRefusal } from '../src/client/branch.ts' + +const CHILD = 'child' as SessionId + +/** + * Build a root context whose Session face is the reduced one these helpers use. + * @param options - What the addressed Session's scope carries. + * @returns The context and the notice spy. + */ +function bench(options: { + readonly scope?: 'composer' | 'no-composer' | 'absent' +}) { + const ctx = new Context() + const notify = vi.fn() + const scope = options.scope ?? 'composer' + ctx.provide('sessions', { + scope: () => (scope === 'absent' ? undefined : { + get: (name: string) => (scope === 'composer' && name === 'conversation' + ? { input: { for: () => ({ notify }) } } + : undefined), + }), + } as never) + return { ctx, notify } +} + +describe('readForkRefusal', () => { + it('reads the code and message a rejection carries, wherever it carries them', () => { + expect(readForkRefusal({ rpcError: { code: 'fork-not-permitted', message: 'not allowed' } })) + .toEqual({ code: 'fork-not-permitted', message: 'not allowed' }) + expect(readForkRefusal(Object.assign(new Error('refused'), { code: 'share-stopped' }))) + .toEqual({ code: 'share-stopped', message: 'refused' }) + }) + + it('names the placeholder code only when the rejection named none', () => { + expect(readForkRefusal(new Error('fork failed'))).toEqual({ code: 'unknown', message: 'fork failed' }) + expect(readForkRefusal('a rejection that is not an object')).toEqual({ code: 'unknown', message: '' }) + // An empty code or message is the same as an absent one: the helper never states a blank. + expect(readForkRefusal({ code: '', rpcError: { code: '', message: '' } })) + .toEqual({ code: 'unknown', message: '' }) + }) +}) + +describe('noteBranchRefusal', () => { + it('states the Host\'s message with its stable code', () => { + const b = bench({}) + noteBranchRefusal( + b.ctx, CHILD, { code: 'boundary-not-committed', message: 'boundary is not committed' }, + reason => `未能完成分支:${reason}`, + ) + expect(b.notify).toHaveBeenLastCalledWith('error', '未能完成分支:boundary is not committed (boundary-not-committed)') + }) + + it('states the code alone when the rejection carried no message of its own', () => { + const b = bench({}) + noteBranchRefusal(b.ctx, CHILD, { code: 'share-expired' }, reason => `未能完成分支:${reason}`) + expect(b.notify).toHaveBeenLastCalledWith('error', '未能完成分支:share-expired') + }) + + it('says nothing when the Session it was asked of has no composer to state it on', () => { + const absent = bench({ scope: 'absent' }) + noteBranchRefusal(absent.ctx, CHILD, { code: 'share-stopped' }, reason => reason) + expect(absent.notify).not.toHaveBeenCalled() + // A scope without the Conversation face is the same silence, reached without a throw. + const bare = bench({ scope: 'no-composer' }) + noteBranchRefusal(bare.ctx, CHILD, { code: 'share-stopped' }, reason => reason) + expect(bare.notify).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index c84953db02..a5e0ceaeb8 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -106,6 +106,9 @@ interface BenchOptions { attachments?: readonly ComposerAttachment[] /** Upload states served for file-kind drafts (absent = every file is ready). */ fileUploads?: DraftFileUploads + acceptsFiles?: boolean + /** Endpoints the Session's access snapshot excludes (absent = every endpoint is allowed). */ + deniedEndpoints?: readonly string[] addFiles?: (files: readonly File[], directories?: ReadonlySet) => string | null commandMenuOpen?: boolean busyEnter?: 'queue' | 'steer' @@ -193,6 +196,7 @@ function bench(over?: BenchOptions) { items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, })), ...allowAllAccess, + useAccess: endpoint => !(over?.deniedEndpoints ?? []).includes(endpoint), useProjection: ((key: string, selector?: (v: unknown) => unknown) => (selector ?? (v => v))(key === 'plan' ? over?.plan @@ -203,6 +207,7 @@ function bench(over?: BenchOptions) { inputActions: shell.actions, keyboard: shell, addFiles: over?.addFiles ?? (() => null), + useFileIntake: bindSnapshotSelector(createSnapshotStore(over?.acceptsFiles ?? true)), useFileUploads: bindSnapshotSelector(createSnapshotStore(over?.fileUploads ?? {})), retryFileUpload: undefined, removeAttachment, @@ -708,6 +713,23 @@ describe('image draft rail', () => { } }) + it('keeps text editable but refuses attachment-slot, picker and paste intake without permission', () => { + const added = vi.fn(() => null) + const result = bench({ acceptsFiles: false, addFiles: added, draft: 'text stays editable' }) + const attachment = attachmentOwner(result.slotCalls) + expect(attachment.canAcceptDrop).toBe(false) + const file = new File(['note'], 'note.txt') + attachment.onAddFiles([file]) + const picker = result.view.container.querySelector('input[type=file]')! + expect(picker.disabled).toBe(true) + fireEvent.change(picker, { target: { files: [file] } }) + fireEvent.paste(result.textarea, { clipboardData: { + items: [{ kind: 'file', type: 'text/plain', getAsFile: () => file }], getData: () => '', + } }) + expect(added).not.toHaveBeenCalled() + expect(result.textarea.getAttribute('contenteditable')).toBe('true') + }) + it('announces a rejected attachment-slot intake through the same toast', () => { const addFiles = vi.fn(() => '图片读取服务不可用') const result = bench({ addFiles }) @@ -1197,6 +1219,41 @@ describe('running and lock semantics', () => { expect(sink).toHaveBeenCalledWith('直接插话', [], 'steer', expect.any(AbortSignal)) }) + it('a running composer whose access excludes prompting and stopping offers neither Stop nor Send', () => { + const { view, stop } = bench({ + running: true, + // The bar is handed a live Stop handler all the same, so what withholds the action is the access + // answer and not a missing face. + deniedEndpoints: ['session/prompt', 'session/cancel'], + blocked: { reason: '只读查看:本页由 owner 分享,无法发送消息' }, + placeholder: '只读查看:本页由 owner 分享,无法发送消息', + }) + expect(view.container.querySelector('button[aria-label="停止生成"]')).toBeNull() + expect(view.container.querySelector('button[aria-label="发送消息"]')).toBeNull() + expect(stop).not.toHaveBeenCalled() + }) + + it('a running composer whose access excludes only prompting keeps Stop but offers no busy Send', () => { + const empty = bench({ running: true, deniedEndpoints: ['session/prompt'] }) + expect(empty.view.getByLabelText('停止生成')).toBeTruthy() + cleanup() + // A draft would make the primary action the busy Send, which the snapshot excludes. + const drafted = bench({ running: true, deniedEndpoints: ['session/prompt'], draft: '排到下一轮' }) + expect(drafted.view.container.querySelector('button[aria-label="排队发送"]')).toBeNull() + }) + + it('an idle composer whose access excludes prompting offers no Send', () => { + const { view } = bench({ deniedEndpoints: ['session/prompt'], draft: 'hello' }) + expect(view.container.querySelector('button[aria-label="发送消息"]')).toBeNull() + }) + + it('a composer whose access has no answer yet keeps its ordinary Send', () => { + const { view, props } = bench({ draft: 'hello' }) + // Without a bound Session the Session-scope Hook answers undefined, which denies nothing. + view.rerender( undefined} />) + expect(view.getByLabelText('发送消息')).toBeTruthy() + }) + it('running Cmd/Ctrl+Enter uses the opposite of the busy-state Enter preference', () => { const meta = bench({ running: true, busyEnter: 'steer', draft: '排到下一轮' }) fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true }) @@ -1959,6 +2016,21 @@ describe('command launcher chrome and control seats', () => { expect(launcher.getAttribute('aria-expanded')).toBe('true') }) + it('follows the access answers over a running Session, and restores the actions when they return', () => { + const { view, props } = bench({ running: true }) + // While the page may stop the running Session, the primary action is its Stop. + expect(view.getByLabelText('停止生成')).toBeTruthy() + + // The access snapshot now excludes the Session's writes: the bar offers neither Stop nor Send. + view.rerender( false} />) + expect(view.queryByLabelText('停止生成')).toBeNull() + expect(view.queryByLabelText('发送消息')).toBeNull() + + // The answers return: the bar offers the action the page holds again. + view.rerender() + expect(view.getByLabelText('停止生成')).toBeTruthy() + }) + it('opening the command menu from the button puts the keyboard in the editor first', () => { const toggleCommandMenu = vi.fn() const { view, textarea } = bench({ toggleCommandMenu }) diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 16e468dccb..b8345b2605 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -73,6 +73,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled inputActions: shell.actions, keyboard: shell, addFiles: () => null, + useFileIntake: bindSnapshotSelector(createSnapshotStore(true)), useFileUploads: bindSnapshotSelector(createSnapshotStore({})), retryFileUpload: undefined, removeAttachment: () => {}, diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 5a0f2d6fba..b627ed99b7 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -175,6 +175,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo inputActions: shell.actions, keyboard: shell, addFiles: () => null, + useFileIntake: bindSnapshotSelector(createSnapshotStore(true)), useFileUploads: bindSnapshotSelector(createSnapshotStore({})), retryFileUpload: undefined, removeAttachment: () => {}, diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index b278797d5d..b21eddf60e 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -300,6 +300,7 @@ function mount( inputActions={inputActions} keyboard={wiring} addFiles={() => null} + useFileIntake={bindSnapshotSelector(createSnapshotStore(true))} useFileUploads={bindSnapshotSelector(createSnapshotStore({}))} retryFileUpload={undefined} removeAttachment={() => {}} diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index a82b593ea8..b3f65169e0 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -36,7 +36,7 @@ The `present` tool's preparing stage is one non-expandable document-icon row. It The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../deliverables/deliverable-file-references/README.md) owns file-count limits and Session declarations. A single delivery fills the row; multiple deliveries form a two-column grid, collapsed after four files. Cards show the basename, description or file type, and a shared native opening control. Delivery and changed-file icon tiles use white at 50% opacity in the light theme and 5% in the dark theme. Icon tiles and single-file changed cards use the same border as delivery cards. Clicking the card previews the file in the right Sidebar. The control opens the default application and lists associated applications, with file reveal last. Matching inline-code references also open Sidebar previews. Delivery cards appear in the turn tail only after the turn ends, so a turn waiting on a blocking question shows none yet; the `present` tool description directs the model to link files in its message for earlier review. Cards include every declaration in the turn, including declarations recorded after the closing reply; duplicate declarations use the last description. -The `present` tool row retains its document icon and recorded result across progress, success, failure, and interruption. Native actions share pending state and publish progress or retryable errors on the card. Successful open or reveal feedback stays visible for five seconds, fades over 200ms, then restores the file description; failures remain visible until another attempt. Desktop metadata is loaded when cards mount and invalidated on connection replacement. Without a Host desktop, cards retain Sidebar preview but omit native controls; failed metadata reads offer Retry. Delivery cards and change review expose `deliverables.file.actions` and `deliverables.review.file.actions`, supplied by [ui-open-in-app](../ui-open-in-app/README.md). Association GET requests and native POST actions both validate the recorded file coordinates and Session filesystem mapping before using the Host desktop. +The `present` tool row retains its document icon and recorded result across progress, success, failure, and interruption. Native actions share pending state and publish progress or retryable errors on the card. Successful open or reveal feedback stays visible for five seconds, fades over 200ms, then restores the file description; failures remain visible until another attempt. Desktop metadata is loaded when cards mount and invalidated on connection replacement; a page whose injected access baseline is not the owner's issues no such read, so its cards keep the Sidebar preview and their unavailable fallback, and the native controls and the owner-only reads behind them stay off. Without a Host desktop, cards retain Sidebar preview but omit native controls; failed metadata reads offer Retry. Delivery cards and change review expose `deliverables.file.actions` and `deliverables.review.file.actions`, supplied by [ui-open-in-app](../ui-open-in-app/README.md). Association GET requests and native POST actions both validate the recorded file coordinates and Session filesystem mapping before using the Host desktop. ### The changed-files card diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 52169d6b34..6196e43fde 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -36,7 +36,7 @@ kind: "package-reference" Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present`,用于声明会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建后以 `files: [{ path, description? }]` 调用。[present 工具](../../deliverables/deliverable-file-references/README.zh.md)拥有文件数量限制和会话声明。单个交付占满一行,多个交付使用双列网格,超过四个时默认收起。卡片显示文件名、说明或文件类型,以及共享的原生打开控件。交付和改动文件卡片的图标底块在浅色主题下使用 50% 不透明度的白色,深色主题下为 5%。图标底块与单文件改动卡片的外框使用与交付卡片相同的描边。点击卡片会在右侧 Sidebar 预览文件。控件打开默认应用,并列出关联应用,文件定位始终放在最后。匹配的行内代码引用也打开 Sidebar 预览。交付卡片只在本轮结束后出现在 turn tail 中,因此等待阻塞提问的轮次暂时没有卡片;`present` 工具描述会引导模型在需要提前审阅时于消息中链接文件。卡片包含本轮的所有声明,包括收尾回复之后记录的声明;重复声明使用最近一次的说明。 -`present` 工具行在执行、成功、失败和中断状态下保留文档图标及记录的结果。原生操作共享等待状态,并在卡片显示进度或可重试错误。打开或定位成功的反馈显示五秒后,在 200ms 内淡出并恢复文件说明;失败信息保留到下次尝试。卡片挂载时读取桌面信息,连接更换时清除缓存。Host 没有桌面时,卡片保留 Sidebar 预览并隐藏原生控件;元数据读取失败时提供重试。交付卡片和变更对比页通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 使用 [ui-open-in-app](../ui-open-in-app/README.zh.md) 提供的控件。关联应用 GET 查询与原生 POST 操作都会先校验记录中的文件坐标及会话文件系统映射,再调用 Host 桌面。 +`present` 工具行在执行、成功、失败和中断状态下保留文档图标及记录的结果。原生操作共享等待状态,并在卡片显示进度或可重试错误。打开或定位成功的反馈显示五秒后,在 200ms 内淡出并恢复文件说明;失败信息保留到下次尝试。卡片挂载时读取桌面信息,连接更换时清除缓存;注入的访问基线不属于 owner 的页面不会发起该读取,其卡片保留 Sidebar 预览与不可用回退,原生控件及其背后的 owner-only 读取都保持关闭。Host 没有桌面时,卡片保留 Sidebar 预览并隐藏原生控件;元数据读取失败时提供重试。交付卡片和变更对比页通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 使用 [ui-open-in-app](../ui-open-in-app/README.zh.md) 提供的控件。关联应用 GET 查询与原生 POST 操作都会先校验记录中的文件坐标及会话文件系统映射,再调用 Host 桌面。 ### 改动文件卡片 diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index ef5bc4b06d..1cf8d7a816 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -39,15 +39,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } } -/** Required services for the tail-slot and tab-type registrations and their dictionaries. */ -export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.session', 'sidebarRightTabs', 'sidebarRight', 'configForms'] +/** Required services for the tail-slot and tab-type registrations, their dictionaries, and page access. */ +export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.session', 'sidebarRightTabs', 'sidebarRight', 'configForms', 'remoteAccess'] /** * Client plugin body: register the dictionaries, the turn-tail entry, and the comparison tab type. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - const opener = new PresentedOpenController() + // A page a business package re-labeled for another Peer must not read the Owner's host routes. + const opener = new PresentedOpenController(ctx.remoteAccess.owner) const summaries = new ChangesSummaryStore() const diffs = new ChangesDiffStore() ctx.effect(() => () => Promise.all([opener.dispose(), summaries.dispose(), diffs.dispose()])) diff --git a/packages/client/ui-deliverables/src/client/present-open.ts b/packages/client/ui-deliverables/src/client/present-open.ts index c116de3a44..847581897f 100644 --- a/packages/client/ui-deliverables/src/client/present-open.ts +++ b/packages/client/ui-deliverables/src/client/present-open.ts @@ -27,6 +27,14 @@ export class PresentedOpenController { private readonly lifetime = new AbortController() private readonly pending = new Set>() + /** + * @param ownerHostRoutes - whether the Owner's own HTTP host routes are reachable from this page. + * False on a page a business package re-labeled for another Peer: `present.host` and the open, + * reveal, and changes routes behind it belong to the Owner's origin, so leaving `host` null keeps + * the cards' unavailable fallback and stops the dependent owner-only reads. + */ + constructor(private readonly ownerHostRoutes = true) {} + /** * Open a declared file once while a request for the same coordinates is pending. * Failures remain visible on the card and a later gesture retries them. @@ -77,7 +85,7 @@ export class PresentedOpenController { * @returns after metadata or a retryable error is published. */ async loadHost(): Promise { - if (this.lifetime.signal.aborted) return + if (!this.ownerHostRoutes || this.lifetime.signal.aborted) return if (this.loading !== undefined) return this.loading this.host.set(null) const task = this.readHost(AbortSignal.any([this.lifetime.signal, this.metadata.signal])) diff --git a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx index d58d4fd6e5..3be6e57d9b 100644 --- a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx @@ -864,6 +864,7 @@ describe('plugin registration', () => { session, } as never) ctx.provide('remote.session', session as never) + ctx.provide('remoteAccess', { owner: true } as never) ctx.provide('configForms', { developerTools: { enabled: createSnapshotStore(true) }, get: () => stubConfigForm().scope } as never) await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() diff --git a/packages/client/ui-deliverables/tests/present-open.client.spec.ts b/packages/client/ui-deliverables/tests/present-open.client.spec.ts index f6910df3f9..69b2fd6043 100644 --- a/packages/client/ui-deliverables/tests/present-open.client.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.client.spec.ts @@ -131,6 +131,19 @@ it('coalesces metadata reads and suppresses their publication after disposal', a }) +it('issues no owner-only host read on a page a business package re-labeled for another Peer', async () => { + const fetcher = vi.fn().mockResolvedValue(Response.json({ name: 'owner-host', available: true, fileManager: 'finder' })) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController(false) + await controller.loadHost() + controller.resetHost() + await controller.loadHost() + // Unread metadata keeps the card's unavailable fallback, and the reads that hang off it stay off too. + expect(fetcher).not.toHaveBeenCalled() + expect(controller.host.getSnapshot()).toBeNull() + await controller.dispose() +}) + it('invalidates cached desktop metadata without eagerly fetching an unused Host', async () => { const fetcher = vi.fn().mockResolvedValue(Response.json({ name: 'old', available: false, fileManager: null })) vi.stubGlobal('fetch', fetcher) diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index 938b893b40..46fab00ee1 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -17,6 +17,7 @@ export const zh = { 'shortcut.directoryBusy': '正在选择或添加工作区', 'shortcut.noCompletedTurn': '当前会话没有已结束的轮次', 'shortcut.forkFailed': '无法分叉会话,请重试', + 'shortcut.notPermitted': '当前页面不允许此操作', 'section.workspaces': '工作区', 'section.sessions': '会话', 'viewOptions.label': '视图选项', @@ -143,6 +144,7 @@ export const en = { 'shortcut.directoryBusy': 'Selecting or adding a workspace', 'shortcut.noCompletedTurn': 'This session has no completed turn', 'shortcut.forkFailed': 'Could not fork the session. Try again.', + 'shortcut.notPermitted': 'This page may not perform that action', 'section.workspaces': 'Workspaces', 'section.sessions': 'Sessions', 'viewOptions.label': 'View options', diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index 6b4221ae19..fc540b78fa 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -59,11 +59,12 @@ export interface UiWorkspace { options?: Pick, ): Promise /** - * Fork a Session without changing the current selection. + * Fork a Session. Pages whose access snapshot includes renaming it increment the child title without + * selecting it; other pages leave the title unchanged and open the child once it is listed. * @param sessionId - source Session. * @param onCreated - observer before the optional child-title update. * @param options - optional permission to start source migration; omission preserves the Host default. - * @returns the child SessionId after creation and inherited-title increment. + * @returns the child SessionId after creation and any permitted inherited-title increment. */ forkSession( sessionId: SessionId, @@ -71,6 +72,14 @@ export interface UiWorkspace { options?: Pick[0], 'allowMigration'>, ): Promise /** + * Open a created Session once its id is known to the list. Later navigation or plugin disposal + * cancels the wait; a completed refresh without that id or a failed refresh leaves selection intact. + * @param childId - Session the Host created. + */ + openListedBranch(childId: SessionId): void + /** + * Resolve the Session for a new conversation in a Workspace: the Host reuses + * the first listed blank member while it is reusable and otherwise creates one, * Resolve the Session for a new conversation in a Workspace: navigation names the first * reusable blank member, skipping blanks that list a preset other than a requested one, and * the Host reuses it under the requested preset or its default, or creates another Session. @@ -325,12 +334,65 @@ class UiWorkspaceService extends Service implements UiWorkspace { onCreated?: (childId: SessionId) => void, options?: Pick[0], 'allowMigration'>, ): Promise { - return this.sessions.fork({ + // Naming a branch renames the child. A page whose access snapshot excludes renaming the source + // Session leaves the name to the Host — asking for it would fail the fork it just made. + const namesBranch = this.ctx.remoteAccess.source('session', sessionId, 'session/rename').getSnapshot() + const childId = await this.sessions.fork({ sessionId, - increaseTitle: true, + increaseTitle: namesBranch, ...onCreated === undefined ? {} : { onCreated }, ...options?.allowMigration === undefined ? {} : { allowMigration: options.allowMigration }, }) + if (!namesBranch) { + // A page that may not rename the Session it branched presents the child only once the Host's own + // summary of it is on the list; opening sooner assembles a view of a branch it cannot describe. + this.openListedBranch(childId) + } + return childId + } + + /** @inheritdoc */ + openListedBranch(childId: SessionId): void { + const navigation = AbortSignal.any([this.ctx.layout.beginNavigation(), this.lifetime.signal]) + if (navigation.aborted) return + let stop = (): void => {} + let settled = false + const finish = (): void => { + settled = true + stop() + navigation.removeEventListener('abort', finish) + } + const open = (): void => { + if (settled || navigation.aborted || this.sessions.list.getSnapshot().byId[childId] === undefined) return + finish() + try { + this.replaceMain(childId, navigation, 'reveal') + } catch (error: unknown) { + // oxlint-disable-next-line typescript/no-unnecessary-condition -- retain may synchronously abort navigation before throwing. + if (!navigation.aborted) this.notify({ kind: 'createFailed', message: creationFailureMessage(error) }) + } + } + stop = this.sessions.list.subscribe(open) + navigation.addEventListener('abort', finish, { once: true }) + // Registration may synchronously publish the row; always recheck after the disposer is owned. + // oxlint-disable-next-line typescript/no-unnecessary-condition -- subscribe may synchronously call open and finish. + if (settled) { + stop() + navigation.removeEventListener('abort', finish) + return + } + open() + // oxlint-disable-next-line typescript/no-unnecessary-condition -- open synchronously settles a listed child. + if (settled) return + void this.sessions.refresh().then(() => { + open() + finish() + }, (error: unknown) => { + if (!settled && !navigation.aborted) { + this.notify({ kind: 'createFailed', message: creationFailureMessage(error) }) + } + finish() + }) } startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions): void { diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index fd708fbebe..f273864029 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -253,6 +253,7 @@ type SessionTreeProps = Pick< WorkspaceBrowserProps, 'useSessionStatus' | 'startSession' | 'open' | 'insertWorkspaceBefore' | 't' | 'usePanelInfo' + | 'useRemoteAccess' | 'useSessionAccess' | 'useWorkspaceAccess' > & PropsRenderSlots< | 'sidebar.workspaces.session.menu.item' | 'sidebar.workspaces.session.row.action' @@ -317,7 +318,7 @@ function EmptySessions({ rowState, onLeaveArchivedOnly, t }: Pick row.id === 'session.new')} group={group} + useRemoteAccess={useRemoteAccess} + useWorkspaceAccess={useWorkspaceAccess} containsCurrentDescendant={currentAncestors.has(group.key)} home={home} t={t} @@ -634,6 +637,7 @@ function SessionTree({ : undefined} drag={dragProps} home={home} + useSessionAccess={useSessionAccess} t={t} /> ) @@ -689,12 +693,13 @@ function SessionTree({ /** The flat "In one list" body: every session is one draggable top-level row. */ function FlatList({ - list, sessionIds, rowState, onLeaveArchivedOnly, useSessionStatus, open, onSessionRenameRequest, + list, sessionIds, rowState, onLeaveArchivedOnly, useSessionStatus, useSessionAccess, open, onSessionRenameRequest, usePanelInfo, setSessionOrder, workspaceReady, animationResetKey, revealSessionId, onSessionRevealed, renderSlot, home, t, }: Pick< SessionTreeProps, | 'useSessionStatus' + | 'useSessionAccess' | 'open' | 'onSessionRenameRequest' | 'renderSlot' @@ -761,6 +766,7 @@ function FlatList({ onReveal={node.id === revealSessionId ? () => { onSessionRevealed(node.id) } : undefined} + useSessionAccess={useSessionAccess} drag={{ start: () => { dropCommitted.current = false @@ -904,6 +910,9 @@ export function WorkspaceBrowser({ expandSidebar, useSessions, useSessionStatus, + useRemoteAccess, + useSessionAccess, + useWorkspaceAccess, useWorkspaces, useStore, actions, @@ -1425,6 +1434,7 @@ export function WorkspaceBrowser({ : groupBy === 'flat' ? ( ) { const [, setMenuOpen] = useMenuOpenState() const shortcut = useShortcuts(rows => rows.find(row => row.id === 'session.archive')) const archived = useArchived(set => set.has(sessionId)) + const allowed = useSessionAccess(sessionId, archived ? 'workspace/unarchiveSession' : 'workspace/archiveSession') + if (!allowed) return null return ( ) { const archived = useArchived(set => set.has(sessionId)) + const allowed = useSessionAccess(sessionId, archived ? 'workspace/unarchiveSession' : 'workspace/archiveSession') + if (!allowed) return null return ( - - - + {canStartSession && ( + + + + )}
) } diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index cf8bcc1167..efba9af48f 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -135,6 +135,7 @@ export function SidebarRoot({ selectPanel, setPinned, trackEntryClick, + useRemoteAccess, usePanels, useEntryPins, useEntryActions, @@ -149,6 +150,8 @@ export function SidebarRoot({ const hasEntryActions = useEntryActions(registered => registered) const shortcut = useShortcuts(rows => rows.find(row => row.id === 'sidebar.left.toggle')) const newShortcut = useShortcuts(rows => rows.find(row => row.id === 'session.new')) + // A page whose access snapshot excludes starting a Session offers no New Session affordance. + const canStartSession = useRemoteAccess('session/create') const toggleLabel = collapsed ? t('toggle.open') : t('toggle.collapse') // Wide content stays mounted while the collapse animates (fading via // .collapsed .wide), unmounts at settle, and remounts right away on expand. @@ -278,7 +281,8 @@ export function SidebarRoot({ traffic lights and keeps the toggle at the sidebar's top-right. */} {darwinDesktop &&
{toggle}
}
- {/* Expanded, the brand doubles as a New Session shortcut — except on + {/* Expanded, the brand doubles as a New Session shortcut while the + access snapshot allows `session/create` — except on macOS, where it stays part of the logo row's window-drag surface (a button would subtract itself through the global no-drag rule); the collapsed rail's logo is the expand toggle below instead. @@ -307,7 +311,7 @@ export function SidebarRoot({ ) - return darwinDesktop + return darwinDesktop || !canStartSession ? {identity} : ( - + {canStartSession && ( + + + + )} {panels.length > 0 && (