Merge branch 'master' into fix/4823-message-overflow-review-menu
This commit is contained in:
commit
f384ee7418
156 files changed
+3925
-481
No files matched your search
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-11-node-office-kit.md
|
||||
2026-09-11-node-office-kit.md: 36897178014daa839399adb5fdf72a4ca53cec8b
|
||||
2026-09-11-node-office-kit.zh.md: 1255012bf6281abe9a95ac338480cf2424fcbe9a
|
||||
2026-09-11-node-office-kit.md: 2c95b7e27e059f6aaee21970fb30fca9ba1bb11b
|
||||
2026-09-11-node-office-kit.zh.md: bec5d6bb59f987f629ebfd81bb753eee121566f3
|
||||
@@ -24,7 +24,7 @@ DSH exports raster images at configurable resolution, defaulting to 192 DPI for
|
||||
|
||||
The [Office viewer](../../../../packages/client/ui-sidebar-documentpreview/README.md#office-preview) lives under Document Preview’s `client/office/` directory, alongside the loading lifecycle, PDF body, and reader types it uses. Keeping these components in one package removes an independent UI boot entry without creating cross-plugin runtime imports. Its bounded cache validates authorized source metadata, shares pending conversions between readers, cancels only when the final reader leaves, excludes failures, and clears on connection reset. Conversion starts when a user opens a preview. Missing declared font families accompany the PDF and are available from the document toolbar’s warning icon; font-table inventories and unrelated engine defaults are not warnings. The shared preview entry’s `office` cache settings use the existing page-global injection channel because the module boot graph carries package identities, not Loader configuration. Reloading the page adopts updated YAML values.
|
||||
|
||||
Office responses use `documentFileBytes()`, which decodes into one typed byte buffer rather than materializing a JavaScript element array from the binary string. Element-array expansion can exhaust the browser heap for a PDF that the configured Host limits permit. A child-process regression checks byte equality within a fixed heap, while the built browser scenario verifies the transport and PDF Worker together. Cache byte limits do not bound transient transport or viewer memory.
|
||||
Office responses use Typert binary-result projection and Connection multipart framing. The Client receives one `ArrayBuffer`-backed `Uint8Array` without a base64 string or a separate decode buffer. The built browser scenario verifies the multipart attachment and PDF Worker together. Cache byte limits do not bound transient transport or viewer memory.
|
||||
|
||||
The [kit ownership decision](2026-09-14-independent-libreoffice-kit.md) defines npm distribution and bundled offline conversion.
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ DSH 按可配置分辨率导出栅格图片,默认 192 DPI,对应共享 PDF
|
||||
|
||||
[Office 查看器](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md#office-preview)位于文档预览的 `client/office/` 目录,与其使用的加载生命周期、PDF 正文和读取器类型同属一个包。这些组件放在同一包中,既减少一个独立 UI 启动入口,也无需跨插件运行时导入。其有界缓存校验已授权的源元数据,在读取方之间共享待完成转换,仅在最后一个读取方离开时取消,不缓存失败,并在连接重置时清空。用户打开预览时才开始转换。缺失的已声明字体族随 PDF 返回,可通过文档工具栏的警告图标查看;字体表清单与无关的引擎默认字体不构成警告。共享预览入口的 `office` 缓存设置复用页面全局注入通道,因为模块启动图携带包标识而不传递 Loader 配置。重新加载页面后采用更新的 YAML 值。
|
||||
|
||||
Office 响应使用 `documentFileBytes()`,解码使用一个类型化字节缓冲区,不将二进制字符串物化为 JavaScript 元素数组。即使 PDF 符合 Host 配置的大小限制,元素数组展开也可能耗尽浏览器堆内存。子进程回归测试在固定堆容量内检查字节一致性,构建后的浏览器场景则一起验证传输和 PDF Worker。缓存字节限制不约束临时传输或查看器内存。
|
||||
Office 响应使用 Typert 二进制结果投影和 Connection multipart 封装。Client 直接收到一个由 `ArrayBuffer` 支撑的 `Uint8Array`,不会生成 base64 字符串或单独的解码缓冲区。构建后的浏览器场景会同时验证 multipart 附件和 PDF Worker。缓存字节限制不约束临时传输或查看器内存。
|
||||
|
||||
[kit 归属决策](2026-09-14-independent-libreoffice-kit.zh.md)定义 npm 分发和随应用打包的离线转换。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md
|
||||
2026-09-18-session-pin-and-sidebar-archive.md: 262f7b76d6ac7eee21a646eedc93b7d936c86675
|
||||
2026-09-18-session-pin-and-sidebar-archive.zh.md: 86ddf4b58781c5974b93562396c3205cfdd0f9dc
|
||||
2026-09-18-session-pin-and-sidebar-archive.md: ac580979b9419e3f00af860e2dce2655fec51908
|
||||
2026-09-18-session-pin-and-sidebar-archive.zh.md: d4719fe75bb8aa1e2ebe9b89d4d80182a7597447
|
||||
@@ -30,7 +30,7 @@ The existing [mode-switch and provisional-New-Session rules](../simplification/2
|
||||
|
||||
`WorkspaceRegistry.unarchiveSession` removes one id from the global archive set on the same serialized operation chain as archive and other registry writes. An absent id is an idempotent no-op with no durable write or notification. Restoration preserves Workspace membership and Session logs, and it neither restores a pin nor changes saved ordering.
|
||||
|
||||
Unarchive performs no Session-existence probe: removing an id cannot introduce an unknown referent, so even an entry whose Session is gone can be removed without listing persistence. The Remote returns the complete archive set, and the existing `archived` increment carries the same state to other Clients. A Client installs a unary reply only while it remains the latest archive-set request; a newer request, follow increment, or replacement baseline supersedes it. Failed writes leave the set unchanged, and the sidebar logs the rejection while retaining the row.
|
||||
Unarchive performs no Session-existence probe: removing an id cannot introduce an unknown referent, so even an entry whose Session is gone can be removed without listing persistence. The Remote returns the complete archive set, and the existing `archived` increment carries the same state to other Clients. A Client installs a unary reply only while it remains the latest archive-set request; a newer request, follow increment, or replacement baseline supersedes it. Failed writes leave the set unchanged, and the sidebar logs the rejection while retaining the row, except the running-work refusal, which opens the stop-and-archive confirmation ([archiving a Session with running work](2026-09-21-archive-stops-running-session-work.md)).
|
||||
|
||||
### Archived visibility and row motion
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ Status: implemented
|
||||
|
||||
`WorkspaceRegistry.unarchiveSession` 与归档及其他注册表写入共用串行操作链,从全局归档集合移除一个 id。id 不在集合中时是幂等空操作,不产生持久写入或通知。恢复保留 Workspace 成员关系与 Session 日志,不恢复置顶,也不改变保存的顺序。
|
||||
|
||||
取消归档不探测 Session 是否存在:移除 id 不会引入未知引用,因此即使 Session 已不存在,也能在不列举持久化记录的情况下移除归档项。Remote 返回完整归档集合,既有 `archived` 增量把同一状态传给其他 Client。Client 只在一元响应仍属于最新归档集合请求时安装它;更新的请求、follow 增量或替换基线会覆盖旧响应的资格。写入失败不改变集合,侧边栏记录拒绝原因并保留原行。
|
||||
取消归档不探测 Session 是否存在:移除 id 不会引入未知引用,因此即使 Session 已不存在,也能在不列举持久化记录的情况下移除归档项。Remote 返回完整归档集合,既有 `archived` 增量把同一状态传给其他 Client。Client 只在一元响应仍属于最新归档集合请求时安装它;更新的请求、follow 增量或替换基线会覆盖旧响应的资格。写入失败不改变集合,侧边栏记录拒绝原因并保留原行;唯一例外是“仍有工作在跑”的拒绝,它会打开停止并归档的确认([归档仍有工作在跑的 Session](2026-09-21-archive-stops-running-session-work.zh.md))。
|
||||
|
||||
### 归档显隐与行动画
|
||||
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.md
|
||||
2026-09-21-archive-stops-running-session-work.md: 2df18c00e6a561e3d85820cbf472bacf5acd9b1d
|
||||
2026-09-21-archive-stops-running-session-work.zh.md: 423a01bd2feab61c62fa0f4b4b4296674e6b24e4
|
||||
@@ -0,0 +1,41 @@
|
||||
# Agent Note: Archiving a Session with running work
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-21-archive-stops-running-session-work.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Archiving was a pure visibility write: `WorkspaceRegistry.archiveSession` added the id to the registry-global archive set and nothing else. A Session archived in the middle of a turn kept running out of sight — the agent, its tool subprocess, and its model requests continued to completion — and the archived row deliberately shows no status dot, so nothing told the user an agent was still spending tokens. A background subagent, an owned job, or a due reminder could likewise keep working or start a new turn in a hidden Session. The Web surface never disposes an Agent, so the exposure lasted until the Host process exited.
|
||||
|
||||
## Decision
|
||||
|
||||
Archive never hides running work. The registry owns the rule and declares one capability seam for it: `workspace/session-activity` (waterfall) asks providers what still runs for a Session; `workspace/session-stop` (parallel) asks them to stop it. Providers register on the root the way any plugin registers a listener, so the workspace package knows no agent, job, or schedule vocabulary. The `SessionActivityKindMap` is empty in the workspace package: each provider merges its own family (`turn` from the Agent registry, `job`, `subagent`, `schedule`) from a module both its faces import, and a consumer that renders them imports those modules for its cases and falls through to a generic line for any other key.
|
||||
|
||||
- **Refuse by default.** `archiveSession(sessionId)` rejects a non-empty activity answer with `WorkspaceActiveSessionError`; the API maps it to `workspace/session-active`, whose details carry the families with item ids and labels. A caller that did not ask to stop work never hides it, whichever client or SDK it is.
|
||||
- **Stop on request, after the write.** `archiveSession(sessionId, { stopActivity: true })` skips the activity check, writes the archive, and then dispatches the stop event: the durable archive set is what the pre-step gate reads, so every wake a stop induces — a cancelled child's settlement, a queued follow-up — is already blocked. The stops are issued and never awaited to settlement, so the Remote returns once every provider's request is out and the user's row hides at once; a stop that fails is a logged warning, not a reason to undo the archive.
|
||||
- **The stop is the user's stop.** The Agent registry cancels the turn with `agent.cancel({ kind: 'user' })` — the stop button's path, so the aborted tool call settles as a `tool/result` and the turn ends as `aborted`, but without the button's `keepInbox`, so queued input is discarded with a logged inbox splice. The job registry seam kills the owned jobs, installed by its constructor for every implementation through the abstract `list` and `kill`. The Subagent runtime cancels running subagent descendants (durable lineage, subagent origin, any depth, never a fork) as their parent would. Each request is contained on its own, so one child or job that throws does not keep the others running. The Schedule plugin deletes every active reminder as a management delete in its transaction queue — the same durable change the `schedule_delete` tool records, between the same two persistence barriers — answering from its owned runtime's fold of the live log rather than from the projection registry, which is a Client view. Each provider owns exactly the vocabulary it already has: the Agent registry knows turns, the job seam knows jobs, the Subagent runtime knows children, the Schedule plugin knows reminders; the API Session Controller keeps only the archived-lineage step gate. No new event type and no write inside another owner's turn, so a restored Session continues from a regularly ended log and unarchive brings nothing back that was stopped.
|
||||
- **Archived means no model step.** The API Session Controller's `agent/pre-step` listener rejects a step proposed for an archived Session or for a subagent descendant of one, which the loop ends as `blocked` without a request. This closes the window in which a late waking delivery — a subagent settlement, a queued follow-up, a turn started between the activity answer and the write — would run a hidden turn, and keeps a child whose cancel was ignored from working for a parent nobody can see; unarchiving lifts the gate for the lineage.
|
||||
- **The refusal is the confirmation.** The sidebar sends the plain archive first; a quiet Session archives without a dialog as before. The `workspace/session-active` refusal opens the stop-and-archive dialog, whose list comes from the Host's answer rather than a client-side mirror of running state, so the dialog names exactly what the Host will stop. Confirming resends with `stopActivity`; the notice offers undo, which restores the Session without resuming the stopped work.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Refuse only, and make the user stop each item.** Reasonix desktop does this. It keeps archive non-destructive, but a stopped Session with a background subagent still running would be refused, and the user would have to hunt down subagents, jobs, and reminders by hand; reminders have no Web control at all. The confirmation dialog states the destruction, which was the only reason to prefer refusal.
|
||||
|
||||
**Wait for settlement before writing.** Codex bounds the wait at ten seconds and proceeds; MiniMax refuses when a turn lock survives its budget. Both keep the user's click pending on the slowest cancel. The stops here are cheap synchronous requests, the pre-step gate guarantees no hidden model step regardless of settlement, and a failed cleanup is acceptable, so the wait moved out of the request path entirely.
|
||||
|
||||
**Stop from the client before archiving.** OpenCode hides in the client and never stops, and its running Sessions vanish from the list while their status events continue. A client-owned semantic reaches one client; the registry-owned seam reaches every caller.
|
||||
|
||||
**Grey the archive action from a client-side activity mirror.** It would need its own derivation of running turns, running children, owned jobs, and active reminders from the list projections, and it could disagree with the Host (an unowned job, a child without a Host Agent). One refused round trip yields the Host's own list instead.
|
||||
|
||||
**Give the workspace or schedule package a dependency on the agent registry.** The waterfall and parallel events let the Agent registry, the job registry seam, the Subagent runtime, and the Schedule plugin provide their families without new dependency edges into the entity registry; each gained an optional type-only edge to `dsh-workspace` for the event types, in the natural feature-to-entity direction (`dsh-workspace` depends on none of them, so no cycle).
|
||||
|
||||
**Report the families from the API Session Controller.** The controller sees every live Agent, the job registry, and the lineage fields on Session headers, so it could enumerate turns, children, and jobs itself — but then it would know each owner's vocabulary (descriptors and labels, the parent-cause cancel, job ownership). The Agent registry, the job seam, and the Subagent runtime already own that vocabulary, so each family lives with its owner; the controller keeps only the lineage step gate, which reads the archive set and header fields alone. The gate cannot move into `dsh-workspace` in turn: the owners now depend on its types, so the workspace package cannot depend on the Agent event vocabulary without a cycle.
|
||||
|
||||
**Refuse cold Sessions with active reminders.** A cold reminder fires only after a resume; an archived Session cannot be opened from the sidebar, and a resume through another operation meets the pre-step gate, so nothing runs hidden; only live Sessions report the `schedule` family.
|
||||
|
||||
## Consequences
|
||||
|
||||
An archived Session is guaranteed idle: it either was quiet, or its work was stopped the way the user stops it, and no step runs for it until it is restored. The pin-and-archive decision's "archive needs no confirmation" holds for quiet Sessions only; the running case asks. Stop settlement is best effort: a job that ignores kill or a child that ignores cancel is logged, and the pre-step gate keeps the Session from spending model requests meanwhile. The activity check and the archive write are two steps, so a turn starting between them is hidden, and every step whose pre-step precedes the write still runs with its tool calls, until the first step after the write is blocked. Two residues follow from the write-first order and the gate. A crash between the archive write and the Schedule plugin's delete barrier leaves an archived Session whose reminders are still recorded; unarchiving shows them again, as if the stop had never been requested. A cold archived Session reports no reminders, and an operation that resumes its Agent — rename, model, or queue edits address a Session without unarchiving it — re-arms them: a due one-shot then dispatches its follow-up into a turn the gate ends as `blocked`, so that occurrence is consumed without a model step and is not replayed after unarchive.
|
||||
|
||||
Peer products surveyed for this choice: Codex and Kimi Code stop then archive; Claude Code stops by starving the remote session of writes; Reasonix and MiniMax refuse a busy Session, MiniMax after a bounded cancel; Pi has no archive but aborts before every session switch; OpenCode neither refuses nor stops. Every product that stops does so through its ordinary cancel path so the log ends regularly.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Agent Note:归档仍有工作在跑的 Session
|
||||
|
||||
状态:implemented
|
||||
|
||||
[English](2026-09-21-archive-stops-running-session-work.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
归档原本是一次纯可见性写入:`WorkspaceRegistry.archiveSession` 把 id 加进注册表全局的归档集合,别的什么都不做。在回合中途被归档的 Session 会在看不见的地方继续跑——agent、它的工具子进程、它的模型请求一直跑到结束——而归档行刻意不画状态点,用户无从得知还有 agent 在消耗 token。后台子代理、所属任务或到期提醒同样可能继续工作,或在隐藏的 Session 里开启新回合。Web 面从不销毁 Agent,这种暴露会持续到宿主进程退出。
|
||||
|
||||
## 决策
|
||||
|
||||
归档绝不隐藏正在运行的工作。规则归注册表所有,并为此声明一个能力接缝:`workspace/session-activity`(waterfall)向提供方询问某 Session 还有什么在跑;`workspace/session-stop`(parallel)请它们停止。提供方像任何插件注册监听器一样注册在根上,因此 workspace 包不认识任何 agent、任务或提醒的词汇。`SessionActivityKindMap` 在 workspace 包里是空的:每个提供方从其两个面都导入的模块合并自己的族(Agent 注册表的 `turn`、`job`、`subagent`、`schedule`),渲染它们的消费方为其分支导入这些模块,遇到其他任何键时落到通用行。
|
||||
|
||||
- **默认拒绝。** `archiveSession(sessionId)` 对非空的活动答案以 `WorkspaceActiveSessionError` 拒绝;API 把它映射为 `workspace/session-active`,details 携带各族及各项的 id 与名称。没有要求停止工作的调用方绝不会把工作藏起来,无论它是哪个客户端或 SDK。
|
||||
- **按请求停止,先写后停。** `archiveSession(sessionId, { stopActivity: true })` 跳过活动检查,先写入归档,再派发停止事件:pre-step 门禁读取的正是持久化的归档集合,因此停止所引发的每一次唤醒——被取消子代理的结算、排队的 follow-up——都已被拦下。停止请求只发出、从不等待收敛,因此 Remote 在每个提供方的请求发出后即返回,用户的行随即隐藏;停止失败只是一条 warn 日志,不是撤销归档的理由。
|
||||
- **停止即用户的停止。** Agent 注册表用 `agent.cancel({ kind: 'user' })` 取消回合——与停止按钮同一条路径,因此被打断的工具调用以 `tool/result` 收口、回合以 `aborted` 结束,但不带按钮的 `keepInbox`,因此排队输入被丢弃并记录一次收件箱拼接。任务注册表接缝 kill 所属任务,由其构造函数只通过抽象的 `list` 与 `kill` 为每个实现安装。Subagent runtime 以父身份取消运行中的子代理子孙(持久化血缘、subagent 来源、任意深度、从不包括 fork)。每个请求各自兜住,一个抛错的子代理或任务不会让其余的继续跑。Schedule 插件把每条活动提醒作为其事务队列中的管理删除来删除——与 `schedule_delete` 工具相同的持久化变更,处于同样的两道持久化屏障之间——答案来自其归属 runtime 对活日志的 fold,而不是投影注册表这个 Client 视图。每个提供方只拥有它本来就有的词汇:Agent 注册表认识回合,任务接缝认识任务,Subagent runtime 认识子代理,Schedule 插件认识提醒;API Session Controller 只保留已归档血缘的步骤门禁。没有新事件类型,也不在别的所有者的回合内写入,因此恢复后的 Session 从一份正常收尾的日志继续,取消归档不会带回任何被停止的东西。
|
||||
- **已归档即不跑模型步。** API Session Controller 的 `agent/pre-step` 监听器拒绝为已归档 Session 或其子代理子孙提出的步骤,loop 以 `blocked` 收口且不发请求。这封住了迟到的唤醒投递——子代理结算、排队的 follow-up、在活动作答与写入之间开始的回合——跑出隐藏回合的窗口,也让无视 cancel 的子代理无法替一个谁也看不见的父级干活;取消归档即为整条血缘解除门禁。
|
||||
- **拒绝即确认。** 侧栏先发普通归档;静止的 Session 一如既往无对话框直接归档。`workspace/session-active` 拒绝会打开"停止并归档"对话框,其清单来自宿主的答案而非客户端对运行状态的镜像,因此对话框列出的正是宿主将要停止的内容。确认后带 `stopActivity` 重发;提示提供撤销,撤销只恢复 Session,不会让被停止的工作继续。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**只拒绝,让用户逐项手动停止。** Reasonix 桌面端如此。它保住了归档的非破坏性,但已停止的 Session 若还有后台子代理在跑仍会被拒,用户得自己去追子代理、任务和提醒;提醒在 Web 上根本没有控件。确认对话框已经写明了破坏性,而这本是偏向拒绝的唯一理由。
|
||||
|
||||
**写入前等待收敛。** Codex 把等待限定在十秒然后继续;MiniMax 在回合锁超出预算仍存活时拒绝。两者都让用户的点击悬在最慢的那个取消上。这里的停止是廉价的同步请求,pre-step 门禁无论是否收敛都保证不跑隐藏模型步,清理失败也可接受,因此等待整个移出了请求路径。
|
||||
|
||||
**归档前在客户端停止。** OpenCode 在客户端隐藏且从不停止,它运行中的 Session 从列表消失而状态事件继续。客户端拥有的语义只覆盖一个客户端;注册表拥有的接缝覆盖每一个调用方。
|
||||
|
||||
**用客户端的活动镜像把归档动作置灰。** 那需要从列表投影自行推导运行中的回合、运行中的子代理、所属任务和活动提醒,且可能与宿主不一致(无主任务、没有宿主 Agent 的子代理)。一次被拒绝的往返就能拿到宿主自己的清单。
|
||||
|
||||
**让 workspace 或 schedule 包依赖 agent 注册表。** waterfall 与 parallel 事件让 Agent 注册表、任务注册表接缝、Subagent runtime 和 Schedule 插件无需向实体注册表新增依赖边就能提供各自的族;每一方都为事件类型新增了指向 `dsh-workspace` 的可选纯类型边,方向是自然的"特性依赖实体"(`dsh-workspace` 不依赖它们中的任何一个,因此不成环)。
|
||||
|
||||
**由 API Session Controller 报告各族。** 控制器看得到每个活 Agent、任务注册表和 Session header 上的血缘字段,本可以自己枚举回合、子代理和任务——但那样它就得认识每个 owner 的词汇(描述符与名称、父级原因的取消、任务归属)。Agent 注册表、任务接缝与 Subagent runtime 本来就拥有这些词汇,所以每一族都跟着自己的 owner;控制器只保留只读归档集合与 header 字段的血缘步骤门禁。门禁也不能反过来搬进 `dsh-workspace`:各 owner 现在依赖它的类型,workspace 包再依赖 Agent 的事件词汇就会成环。
|
||||
|
||||
**拒绝带活动提醒的冷 Session。** 冷提醒只在 resume 后才触发;已归档的 Session 无法从侧栏打开,经其他操作 resume 也会撞上 pre-step 门禁,所以没有东西在隐藏运行;只有活 Session 报告 `schedule` 族。
|
||||
|
||||
## 后果
|
||||
|
||||
已归档的 Session 保证空闲:它要么本来就静止,要么其工作已按用户停止的方式停止,且在恢复之前不会为它跑任何一步。置顶与归档决策里的"归档无需确认"仅对静止 Session 成立;运行中的情形会先询问。停止的收敛是尽力而为:无视 kill 的任务或无视 cancel 的子代理会被记录,pre-step 门禁在此期间阻止该 Session 消耗模型请求。活动检查与归档写入是两步,其间开始的回合会被隐藏,pre-step 先于该写入的每一步连同其工具调用照常执行,直到写入之后的第一步被阻断。先写后停与门禁留下两处残余。在归档写入与 Schedule 插件的删除屏障之间崩溃,会留下一个提醒仍被记录的已归档 Session;取消归档时它们再次出现,与从未请求过停止时一样。冷的已归档 Session 不报告提醒,而恢复其 Agent 的操作——重命名、模型、队列编辑都按 Session 寻址而不取消归档——会重新武装它们:到期的一次性提醒把 follow-up 投递进一个被门禁以 `blocked` 收口的回合,该次触发因此在没有模型步的情况下被消耗,取消归档后也不会重放。
|
||||
|
||||
为此决策调研的同行产品:Codex 与 Kimi Code 先停止再归档;Claude Code 通过让远端会话无法写入来停止;Reasonix 与 MiniMax 拒绝忙碌的 Session,MiniMax 在有界取消之后才拒绝;Pi 没有归档但在每次切换会话前 abort;OpenCode 既不拒绝也不停止。每一个会停止的产品都走自己的常规取消路径,以便日志正常收尾。
|
||||
@@ -83,6 +83,26 @@ async function openPreviewFile(column: Locator, filesTab: Locator, preview: Loca
|
||||
await expect.poll(async () => (await preview.getAttribute('data-textpreview-url'))?.endsWith(`/${name}`)).toBe(true)
|
||||
}
|
||||
|
||||
/** Move into the bottom reveal zone and wait for the shared zoom control. */
|
||||
async function revealDocumentZoom(page: Page, preview: Locator): Promise<Locator> {
|
||||
const frame = preview.locator('[data-document-zoom-frame]')
|
||||
const bounds = await frame.boundingBox()
|
||||
if (bounds === null) throw new Error('document zoom frame has no bounds')
|
||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height - 8)
|
||||
const controls = preview.locator('[data-document-zoom-controls]')
|
||||
await expect.poll(() => controls.getAttribute('data-document-zoom-visible')).toBe('true')
|
||||
return preview.getByRole('button', { name: 'Choose zoom', exact: true })
|
||||
}
|
||||
|
||||
/** Leave the bottom reveal zone and wait for the delayed dismissal. */
|
||||
async function hideDocumentZoom(page: Page, preview: Locator): Promise<void> {
|
||||
const frame = preview.locator('[data-document-zoom-frame]')
|
||||
const bounds = await frame.boundingBox()
|
||||
if (bounds === null) throw new Error('document zoom frame has no bounds')
|
||||
await page.mouse.move(bounds.x + 8, bounds.y + 8)
|
||||
await expect.poll(() => preview.locator('[data-document-zoom-controls]').getAttribute('data-document-zoom-visible')).toBeNull()
|
||||
}
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
@@ -468,7 +488,25 @@ else process.exit(1);
|
||||
const canvas = preview.getByRole('img', { name: 'PDF page 1', exact: true })
|
||||
await canvas.waitFor({ state: 'visible', timeout: 30_000 })
|
||||
expect(await viewer.count()).toBe(0)
|
||||
expect(await preview.locator('[role="toolbar"]').count()).toBe(0)
|
||||
const zoomControls = preview.locator('[data-document-zoom-controls]')
|
||||
await expect.poll(() => zoomControls.getAttribute('data-document-zoom-visible')).toBeNull()
|
||||
let pdfZoom = await revealDocumentZoom(page, preview)
|
||||
await hideDocumentZoom(page, preview)
|
||||
pdfZoom = await revealDocumentZoom(page, preview)
|
||||
const pdfWidth = (await canvas.boundingBox())!.width
|
||||
const pdfIntrinsicWidth = await canvas.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--pdf-page-width')))
|
||||
await expect.poll(() => pdfZoom.innerText()).toBe(`${String(Math.round(pdfWidth / pdfIntrinsicWidth * 100))}%`)
|
||||
await pdfZoom.click()
|
||||
await page.getByRole('menuitem', { name: '100%', exact: true }).click()
|
||||
await expect.poll(async () => (await canvas.boundingBox())!.width).toBeCloseTo(pdfIntrinsicWidth, 0)
|
||||
pdfZoom = await revealDocumentZoom(page, preview)
|
||||
await pdfZoom.click()
|
||||
await page.getByRole('menuitem', { name: '150%', exact: true }).click()
|
||||
await expect.poll(async () => (await canvas.boundingBox())!.width / pdfIntrinsicWidth).toBeCloseTo(1.5, 1)
|
||||
pdfZoom = await revealDocumentZoom(page, preview)
|
||||
await pdfZoom.click()
|
||||
await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click()
|
||||
await expect.poll(async () => (await canvas.boundingBox())!.width).toBeCloseTo(pdfWidth, 0)
|
||||
expect(await preview.locator('[data-pdf-page]').count()).toBe(2)
|
||||
await expect.poll(() => canvasColor(canvas), { timeout: 30_000 }).toBe('red')
|
||||
const firstColor = await canvasColor(canvas)
|
||||
@@ -501,6 +539,8 @@ else process.exit(1);
|
||||
`- Viewer menu hidden: ${String(await viewer.count() === 0)}`,
|
||||
`- Worker: ${workerNames.find(name => name === 'dsh-pdf')}`,
|
||||
`- Continuous pages: ${await preview.locator('[data-pdf-page]').count()}`,
|
||||
'- Zoom reveal: hidden -> bottom hover -> delayed hidden',
|
||||
'- Zoom modes: fit width -> 100% -> 150% -> fit width',
|
||||
`- Horizontal overflow: ${String(await body.evaluate(node => node.scrollWidth > node.clientWidth))}`,
|
||||
`- Canvas fills: ${[firstColor, secondColor, restoredColor].join(' -> ')}`,
|
||||
`- Same tab: ${String(await pdfTab.getAttribute('data-dockkit-tab') === pdfTabId)}`,
|
||||
@@ -613,6 +653,16 @@ else process.exit(1);
|
||||
})
|
||||
expect(centering.horizontal).toBeLessThan(10)
|
||||
expect(centering.vertical).toBeLessThan(10)
|
||||
let imageZoom = await revealDocumentZoom(page, preview)
|
||||
await expect.poll(() => imageZoom.innerText()).toBe('100%')
|
||||
const tinyWidth = (await tinyImage.boundingBox())!.width
|
||||
await imageZoom.click()
|
||||
await page.getByRole('menuitem', { name: '200%', exact: true }).click()
|
||||
await expect.poll(async () => (await tinyImage.boundingBox())!.width / tinyWidth).toBeCloseTo(2, 1)
|
||||
imageZoom = await revealDocumentZoom(page, preview)
|
||||
await imageZoom.click()
|
||||
await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click()
|
||||
await expect.poll(() => imageZoom.innerText()).toBe('100%')
|
||||
|
||||
await openFile('large.svg')
|
||||
await expect.poll(() => viewer.innerText()).toBe('Image')
|
||||
@@ -640,12 +690,30 @@ else process.exit(1);
|
||||
// Width fit: the image fills the frame's 12px-inset box while the aspect ratio holds.
|
||||
expect(Math.abs((fitted.paneWidth - 24) - fitted.width)).toBeLessThanOrEqual(1)
|
||||
expect(fitted.height / fitted.width).toBeCloseTo(1600 / 1200, 2)
|
||||
const scrolled = await body.evaluate((node) => {
|
||||
let svgZoom = await revealDocumentZoom(page, preview)
|
||||
await expect.poll(async () => Number.parseInt(await svgZoom.innerText(), 10)).toBeCloseTo(fitted.width / 12, 0)
|
||||
const imageScrollport = preview.locator('[data-document-zoom-scrollport]')
|
||||
const fitScrolled = await imageScrollport.evaluate((node) => {
|
||||
node.scrollLeft = node.scrollWidth
|
||||
return { left: node.scrollLeft, horizontalOverflow: node.scrollWidth > node.clientWidth }
|
||||
})
|
||||
expect(scrolled).toEqual({ left: 0, horizontalOverflow: false })
|
||||
expect(fitScrolled).toEqual({ left: 0, horizontalOverflow: false })
|
||||
await svgZoom.click()
|
||||
await page.getByRole('menuitem', { name: '100%', exact: true }).click()
|
||||
await expect.poll(async () => (await largeImage.boundingBox())!.width).toBeCloseTo(1200, 0)
|
||||
expect(await imageScrollport.evaluate(node => node.scrollWidth > node.clientWidth)).toBe(true)
|
||||
svgZoom = await revealDocumentZoom(page, preview)
|
||||
await svgZoom.click()
|
||||
await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click()
|
||||
await expect.poll(async () => (await largeImage.boundingBox())!.width).toBeCloseTo(fitted.width, 0)
|
||||
expect(await imageScrollport.evaluate(node => node.scrollWidth > node.clientWidth)).toBe(false)
|
||||
expect(await page.locator('html').getAttribute('data-image-preview-escape')).toBeNull()
|
||||
sections.push([
|
||||
'## Image zoom', '',
|
||||
'- Small PNG fit width remains at intrinsic size; 200% doubles it',
|
||||
'- SVG fit width -> 100% -> fit width toggles horizontal overflow: false -> true -> false',
|
||||
'- Image and Blob identities remain stable while zoom changes',
|
||||
].join('\n'))
|
||||
|
||||
const releaseRead = Promise.withResolvers<undefined>()
|
||||
let waitingForRead = false
|
||||
@@ -957,7 +1025,10 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
|
||||
|
||||
it('rejects renamed text and renders Chinese Office documents through the PDF worker', async () => {
|
||||
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false,
|
||||
extraOverlayPath: fileURLToPath(new URL('../../../packages/client/ui-sidebar-documentpreview/tests/fixtures/office-cache.patch.yml', import.meta.url)),
|
||||
extraOverlayPath: [
|
||||
fileURLToPath(new URL('../../../packages/client/ui-sidebar-documentpreview/tests/fixtures/office-cache.patch.yml', import.meta.url)),
|
||||
fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
|
||||
],
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
@@ -990,7 +1061,24 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
|
||||
await column.locator('[data-files-reload]').click()
|
||||
const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) })
|
||||
const preview = column.locator('[data-textpreview-url]')
|
||||
const pdfResponse = page.waitForResponse(
|
||||
response => new URL(response.url()).pathname === '/api/officeToPdf/render',
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'chinese.docx', exact: true }).click()
|
||||
const officeTransfer = await pdfResponse
|
||||
expect(officeTransfer.headers()['content-type']).toMatch(/^multipart\/form-data;/)
|
||||
const officeBody = await new Response(new Uint8Array(await officeTransfer.body()), { headers: officeTransfer.headers() }).formData()
|
||||
const officeMetadata = officeBody.get('metadata')
|
||||
if (typeof officeMetadata !== 'string') throw new Error('missing Office PDF metadata')
|
||||
const { attachments } = JSON.parse(officeMetadata) as { attachments: { path: (string | number)[]; codec: string; part: string }[] }
|
||||
expect(attachments).toHaveLength(1)
|
||||
expect(attachments[0]).toMatchObject({ path: ['data'], codec: 'bytes' })
|
||||
const officeFile = officeBody.get(attachments[0]!.part)
|
||||
if (officeFile === null || typeof officeFile === 'string') throw new Error('missing Office PDF payload')
|
||||
const officePdf = Buffer.from(await officeFile.arrayBuffer())
|
||||
expect(officePdf.subarray(0, 5).toString('ascii')).toBe('%PDF-')
|
||||
expect(officePdf.subarray(-1024).toString('ascii').trimEnd()).toMatch(/%%EOF$/u)
|
||||
expect(await preview.locator('[data-document-viewer-menu]').count()).toBe(0)
|
||||
const canvas = preview.getByRole('img', { name: 'PDF page 1', exact: true })
|
||||
await canvas.waitFor({ state: 'visible', timeout: 60_000 })
|
||||
@@ -1010,6 +1098,27 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
|
||||
await copyPdfText(page, preview, 'Office preview')
|
||||
await copyPdfText(page, preview, '中文文档')
|
||||
expect((await preview.locator('[data-pdf-text]').allTextContents()).join('')).toContain('中文文档')
|
||||
const zoomMenu = await revealDocumentZoom(page, preview)
|
||||
const initialWidth = (await canvas.boundingBox())!.width
|
||||
const intrinsicWidth = await canvas.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--pdf-page-width')))
|
||||
const fitPercent = `${String(Math.round(initialWidth / intrinsicWidth * 100))}%`
|
||||
await expect.poll(() => zoomMenu.innerText()).toBe(fitPercent)
|
||||
await zoomMenu.click()
|
||||
await page.getByRole('menuitem', { name: '150%', exact: true }).click()
|
||||
await expect.poll(() => zoomMenu.innerText()).toBe('150%')
|
||||
await expect.poll(async () => (await canvas.boundingBox())!.width / intrinsicWidth).toBeCloseTo(1.5, 1)
|
||||
const zoomScrollport = preview.locator('[data-document-zoom-scrollport]')
|
||||
await zoomScrollport.evaluate((node) => {
|
||||
const bounds = node.getBoundingClientRect()
|
||||
node.dispatchEvent(new WheelEvent('wheel', { bubbles: true, cancelable: true, ctrlKey: true,
|
||||
deltaY: -10, clientX: bounds.left + bounds.width / 2, clientY: bounds.top + bounds.height / 2 }))
|
||||
})
|
||||
await expect.poll(() => zoomMenu.innerText()).toBe('166%')
|
||||
await expect.poll(async () => (await canvas.boundingBox())!.width / intrinsicWidth).toBeCloseTo(1.66, 1)
|
||||
await zoomMenu.click()
|
||||
await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click()
|
||||
await expect.poll(() => zoomMenu.innerText()).toBe(fitPercent)
|
||||
await zoomScrollport.evaluate((node) => { node.scrollTop = 0; node.scrollLeft = 0 })
|
||||
expect(convert).toHaveBeenCalledTimes(1)
|
||||
await preview.getByRole('button', { name: 'Read the file again', exact: true }).click()
|
||||
await canvas.waitFor({ state: 'visible' })
|
||||
@@ -1061,6 +1170,8 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
|
||||
'- Requested absent family is listed: true',
|
||||
'- Escape restores focus to the warning: true',
|
||||
'- Closing details preserves the warning and document position: true',
|
||||
'- Fit width, presets, and pinch resize the Office PDF continuously: true',
|
||||
'- Pinch updates the displayed percentage during the gesture: 166%',
|
||||
`- Document top inset: ${topInset}px`,
|
||||
].join('\n'), MODE)
|
||||
await successShot(page, 'office-docx')
|
||||
|
||||
@@ -6,4 +6,6 @@
|
||||
- Requested absent family is listed: true
|
||||
- Escape restores focus to the warning: true
|
||||
- Closing details preserves the warning and document position: true
|
||||
- Fit width, presets, and pinch resize the Office PDF continuously: true
|
||||
- Pinch updates the displayed percentage during the gesture: 166%
|
||||
- Document top inset: 0px
|
||||
@@ -0,0 +1,9 @@
|
||||
- dialog "Stop and archive this session?":
|
||||
- heading "Stop and archive this session?" [level=2]
|
||||
- button "Close"
|
||||
- paragraph: “ARCHIVE_ACTIVE run the long command” still has work in progress. Archiving stops it first; you can restore the session later from “Show archived” in the sidebar, and the stopped work will not resume on its own.
|
||||
- list "Work that will be stopped":
|
||||
- listitem: The turn in progress
|
||||
- listitem: "1 background job: node -e \"const fs=require('node:fs'); fs.writeFileSync('started.txt', 'started'); setInterval(() => { fs.appendFileSync('heartbeat.txt', '.') }, 200); setTimeout(() => { fs.writeFileSync('finished.txt', 'finished') }, 60000)\""
|
||||
- button "Cancel"
|
||||
- button "Stop and archive"
|
||||
@@ -258,7 +258,11 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
const originalRow = page.locator('[role="treeitem"]')
|
||||
.filter({ hasText: 'Use the ask_user_question tool' }).first()
|
||||
await page.getByRole('button', { name: 'New session', exact: true }).last().click()
|
||||
await page.getByText('New Session', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
// Scope to the tree: the wide sidebar's New Session button carries the
|
||||
// same visible label, and an unscoped match would either settle on the
|
||||
// button before the row exists or trip strict mode once it does.
|
||||
await page.getByRole('tree', { name: 'Sessions' })
|
||||
.getByText('New Session', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
await expect.poll(() => composer.count(), { timeout: 10_000 }).toBe(0)
|
||||
await originalRow.click()
|
||||
await composer.waitFor({ timeout: 15_000 })
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
// Web e2e: archiving a Session whose turn is still running. The Host refuses
|
||||
// the plain archive, the sidebar turns that refusal into the stop-and-archive
|
||||
// confirmation, and confirming stops the turn the way the stop button does —
|
||||
// the aborted Bash call settles with a tool/result and the turn ends as
|
||||
// cancelled — before the row hides. Unarchiving restores a Session that
|
||||
// continues the conversation. Keyless: the model is a replay override whose
|
||||
// first stream calls Bash with a long-running command, and the second answers
|
||||
// the continuation prompt after the restore.
|
||||
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Locator, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { ToolCallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { ReplayEntry } from '@deepseek-ai/dsh-llm-replay'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, readPersistedEvents,
|
||||
watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/session-archive-active', import.meta.url))
|
||||
const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
const PROMPT = 'ARCHIVE_ACTIVE run the long command'
|
||||
const CONTINUE_PROMPT = 'ARCHIVE_ACTIVE continue after the restore'
|
||||
const CONTINUED = 'ARCHIVE_ACTIVE_CONTINUED'
|
||||
// Long enough for the archive to land mid-call under CI load; the stop must
|
||||
// end the call well before it would finish on its own.
|
||||
const WAIT_MS = 60_000
|
||||
const HEARTBEAT_MS = 200
|
||||
/** The child the Bash call runs: announce, heartbeat, and only finish long after the archive. */
|
||||
const WAIT_COMMAND = `node -e "const fs=require('node:fs'); fs.writeFileSync('started.txt', 'started'); setInterval(() => { fs.appendFileSync('heartbeat.txt', '.') }, ${HEARTBEAT_MS}); setTimeout(() => { fs.writeFileSync('finished.txt', 'finished') }, ${WAIT_MS})"`
|
||||
|
||||
/**
|
||||
* First stream: one Bash call whose child announces itself, then beats on a
|
||||
* file until it finishes; second stream: the continuation answer. The
|
||||
* heartbeat file is the liveness evidence: a pid is not comparable across the
|
||||
* Linux sandbox's PID namespace, a growing file is.
|
||||
*/
|
||||
function script(): ReplayEntry[] {
|
||||
const args = JSON.stringify({ command: WAIT_COMMAND, description: 'Wait a long time' })
|
||||
const callId = ToolCallId('call_wait')
|
||||
const first: StreamChunk[] = [
|
||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||||
{ type: 'tool-call-delta', index: 0, id: callId, name: 'bash', argumentsDelta: args },
|
||||
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: callId, name: 'bash', arguments: args } },
|
||||
{ type: 'usage', usage: { inputTokens: 10, outputTokens: 10 } },
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
]
|
||||
const second: StreamChunk[] = [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: CONTINUED },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text: CONTINUED } },
|
||||
{ type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
return [{ kind: 'chunks', chunks: first }, { kind: 'chunks', chunks: second }]
|
||||
}
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function heartbeats(cwd: string): Promise<number> {
|
||||
try {
|
||||
return (await stat(join(cwd, 'heartbeat.txt'))).size
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the Bash child is still beating: its file grows across one settle window. */
|
||||
async function childAlive(cwd: string): Promise<boolean> {
|
||||
const before = await heartbeats(cwd)
|
||||
await new Promise((resolve) => { setTimeout(resolve, HEARTBEAT_MS * 6) })
|
||||
return (await heartbeats(cwd)) > before
|
||||
}
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: archiving a running Session stops it first', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let replayDir: string
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let sessionId: SessionId
|
||||
|
||||
/**
|
||||
* Reveal and click a row action, re-hovering if a projection update replaces
|
||||
* the row before its hover-only button becomes visible.
|
||||
* @param row - the row owning the action.
|
||||
* @param name - accessible name of the action button.
|
||||
*/
|
||||
async function clickHoverAction(row: Locator, name: string): Promise<void> {
|
||||
const button = row.getByRole('button', { name })
|
||||
await expect.poll(async () => {
|
||||
await row.hover()
|
||||
return await button.isVisible()
|
||||
}, { timeout: 10_000 }).toBe(true)
|
||||
await button.click()
|
||||
}
|
||||
|
||||
/** The one Session row of the fresh workspace group (the group row carries no actions). */
|
||||
function sessionRow(): Locator {
|
||||
return page.locator('[role="treeitem"]').filter({ has: page.locator('button[aria-label^="Session actions for "]') })
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
replayDir = await mkdtemp(join(tmpdir(), 'dsh-session-archive-active-'))
|
||||
const replayOverride = join(replayDir, 'replay.override.json')
|
||||
await writeFile(replayOverride, JSON.stringify(script()))
|
||||
scaffold = await launchWebScaffold({
|
||||
replayFixture: join(replayDir, 'override-only.jsonl'),
|
||||
replayOverride,
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
const failures: unknown[] = []
|
||||
await browser?.close().catch((error: unknown) => failures.push(error))
|
||||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||
if (replayDir !== undefined) {
|
||||
await rm(replayDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
|
||||
}
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'session-archive-active e2e cleanup failed')
|
||||
})
|
||||
|
||||
it('refuses the plain archive of a running Session and asks to stop it instead', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-session-archive-active-ask'))
|
||||
const cwd = join(scaffold.workspaceCwd, 'workspace')
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
await input.waitFor({ timeout: 10_000 })
|
||||
sessionId = await page.evaluate(() => {
|
||||
const selected = JSON.parse(localStorage.getItem('dsh.sessions.current')!) as { sessionId: string }
|
||||
return selected.sessionId
|
||||
}) as SessionId
|
||||
await writeComposerDraft(page, input, PROMPT)
|
||||
await page.getByRole('button', { name: 'Send message', exact: true }).click()
|
||||
await expect.poll(() => exists(join(cwd, 'started.txt')), { timeout: 20_000 }).toBe(true)
|
||||
expect(await childAlive(cwd)).toBe(true)
|
||||
expect(scaffold.ctx.agents.get(sessionId)?.status).toBe('running')
|
||||
|
||||
await clickHoverAction(sessionRow(), 'Session actions for ')
|
||||
await page.getByRole('menuitem', { name: 'Archive session' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Stop and archive this session?' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
// A foreground Bash command is a registered job from its start, so the
|
||||
// Host reports it beside the turn; stopping the turn kills it.
|
||||
await expect.poll(() => dialog.getByRole('list', { name: 'Work that will be stopped' }).locator('li').allTextContents())
|
||||
.toEqual(['The turn in progress', `1 background job: ${WAIT_COMMAND}`])
|
||||
// Asking is not archiving: the Host set is untouched, the turn and its
|
||||
// Bash child keep running, and the row stays in the sidebar.
|
||||
expect([...scaffold.ctx.workspaceRegistry.archivedSessionIds]).toEqual([])
|
||||
expect(scaffold.ctx.agents.get(sessionId)?.status).toBe('running')
|
||||
expect(await childAlive(cwd)).toBe(true)
|
||||
await expect.poll(() => sessionRow().count()).toBe(1)
|
||||
// The Bash command is a running job while the dialog is open, by design.
|
||||
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd, { runningJobs: 'keep' })
|
||||
await compareOrRefreshGolden(DIALOG_EXPECTED, snapshot, MODE)
|
||||
|
||||
// Cancel keeps everything as it was.
|
||||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click()
|
||||
await expect.poll(() => page.getByRole('dialog').count()).toBe(0)
|
||||
expect(scaffold.ctx.agents.get(sessionId)?.status).toBe('running')
|
||||
}, 60_000)
|
||||
|
||||
it('confirming stops the turn like the stop button, then hides the row', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-session-archive-active-confirm'))
|
||||
const agent = scaffold.ctx.agents.get(sessionId)
|
||||
if (agent === undefined) throw new Error('the running Session lost its Agent')
|
||||
const settled = scaffold.whenTurnSettled(30_000)
|
||||
await clickHoverAction(sessionRow(), 'Session actions for ')
|
||||
await page.getByRole('menuitem', { name: 'Archive session' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Stop and archive this session?' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: 'Stop and archive', exact: true }).click()
|
||||
|
||||
await expect.poll(() => [...scaffold.ctx.workspaceRegistry.archivedSessionIds].map(String), { timeout: 10_000 })
|
||||
.toEqual([String(sessionId)])
|
||||
await expect.poll(() => page.getByRole('dialog').count()).toBe(0)
|
||||
await expect.poll(() => sessionRow().count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect.poll(() => page.getByText('Session stopped and archived.', { exact: false }).count(), { timeout: 10_000 })
|
||||
.toBe(1)
|
||||
|
||||
// The stop is the user's cancel: the Bash child dies, the turn settles as
|
||||
// cancelled, and the log closes every open call before the turn ends.
|
||||
expect(await settled).toBe(sessionId)
|
||||
const cwd = join(scaffold.workspaceCwd, 'workspace')
|
||||
await expect.poll(() => childAlive(cwd), { timeout: 10_000, interval: 50 }).toBe(false)
|
||||
expect(agent.status).toBe('idle')
|
||||
const live = agent.session.snapshotEvents().filter(event => event.seq >= agent.session.firstLiveSeq)
|
||||
const calls = live.filter((event): event is SessionEvent<'tool/call'> => event.type === 'tool/call')
|
||||
const results = live.filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result')
|
||||
expect(calls.map(event => event.data.callId)).toEqual(['call_wait'])
|
||||
expect(results.map(event => event.data.message.toolCallId)).toEqual(['call_wait'])
|
||||
expect(results[0]?.data.error?.code).toBe('ABORTED')
|
||||
const turnEnd = live.at(-1)
|
||||
expect(turnEnd?.type).toBe('turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'user' } })
|
||||
expect(await exists(join(scaffold.workspaceCwd, 'workspace', 'finished.txt'))).toBe(false)
|
||||
}, 60_000)
|
||||
|
||||
it('the restored Session continues the conversation from a regular log', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-session-archive-active-restore'))
|
||||
await page.getByRole('button', { name: 'View options' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Show archived', exact: true }).click()
|
||||
await expect.poll(() => sessionRow().count(), { timeout: 10_000 }).toBe(1)
|
||||
await clickHoverAction(sessionRow(), 'Session actions for ')
|
||||
await page.getByRole('menuitem', { name: 'Unarchive session' }).click()
|
||||
await expect.poll(() => [...scaffold.ctx.workspaceRegistry.archivedSessionIds], { timeout: 10_000 }).toEqual([])
|
||||
await sessionRow().click()
|
||||
await expect.poll(() => sessionRow().getAttribute('aria-selected'), { timeout: 10_000 }).toBe('true')
|
||||
|
||||
const composer = page.locator('[data-composer-input]').last()
|
||||
await writeComposerDraft(page, composer, CONTINUE_PROMPT)
|
||||
const settled = scaffold.whenTurnSettled(30_000)
|
||||
await page.getByRole('button', { name: 'Send message', exact: true }).click()
|
||||
await page.getByText(CONTINUED, { exact: false }).last().waitFor({ timeout: 15_000 })
|
||||
expect(await settled).toBe(sessionId)
|
||||
|
||||
// The persisted log is what a later cold resume would read: two turns,
|
||||
// each closed, with the cancelled call settled inside the first.
|
||||
const events = await readPersistedEvents(scaffold, sessionId)
|
||||
const turns = events.filter(event => event.type === 'turn/start').map(event => event.data.turn)
|
||||
expect(turns).toEqual([1, 2])
|
||||
const ends = events.filter((event): event is SessionEvent<'turn/end'> => event.type === 'turn/end')
|
||||
expect(ends.map(event => event.data.reason.kind)).toEqual(['aborted', 'completed'])
|
||||
const calls = events.filter(event => event.type === 'tool/call').length
|
||||
const results = events.filter(event => event.type === 'tool/result').length
|
||||
expect(results).toBe(calls)
|
||||
}, 60_000)
|
||||
|
||||
it('kept the console clean and the fixture inventory closed', async () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -81,6 +81,7 @@
|
||||
"tests/workspace-management.e2e.ts",
|
||||
"tests/workspace-recency.e2e.ts",
|
||||
"tests/session-unarchive.e2e.ts",
|
||||
"tests/session-archive-active.e2e.ts",
|
||||
"tests/replay-round-trip.e2e.ts",
|
||||
"tests/hmr-live.e2e.ts",
|
||||
"tests/preview-boot.e2e.ts",
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/config-catalog.md
|
||||
config-catalog.md: c46008dd88e51dc079d72e6605d06176c26bdea1
|
||||
config-catalog.zh.md: 7376476771902e1bbcba3ae4d6374b1087e14a72
|
||||
config-catalog.md: fca506592b68e02f60746f1a78d3b74c88cb2296
|
||||
config-catalog.zh.md: 9435a87dd7770ea469db2ea4038ae4860a2e83ed
|
||||
@@ -228,7 +228,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/api/session-controller/src/index.ts:78`](../packages/api/session-controller/src/index.ts)
|
||||
Source: [`packages/api/session-controller/src/index.ts:79`](../packages/api/session-controller/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-api-settings-controller"></a>
|
||||
|
||||
@@ -2860,7 +2860,7 @@ export interface Config {
|
||||
|
||||
Depends on: `Volatile` (`@deepseek-ai/cordis`)
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:191`](../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:192`](../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-subagent-acp"></a>
|
||||
|
||||
|
||||
@@ -230,7 +230,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
来源: [`packages/api/session-controller/src/index.ts:78`](../packages/api/session-controller/src/index.ts)
|
||||
来源: [`packages/api/session-controller/src/index.ts:79`](../packages/api/session-controller/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-api-settings-controller"></a>
|
||||
|
||||
@@ -2862,7 +2862,7 @@ export interface Config {
|
||||
|
||||
Depends on: `Volatile` (`@deepseek-ai/cordis`)
|
||||
|
||||
来源: [`packages/subagent/subagent/src/index.ts:191`](../packages/subagent/subagent/src/index.ts)
|
||||
来源: [`packages/subagent/subagent/src/index.ts:192`](../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-subagent-acp"></a>
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
|
||||
event-producer-consumer.md: 50995814a2f4124f9d77d4c1689e28950476f81f
|
||||
event-producer-consumer.zh.md: 6b2c4738859b6526508f73b7aca5cedf27a3ee55
|
||||
event-producer-consumer.md: beee1c62b63e7f637a1f159f0dcce6450e53dd59
|
||||
event-producer-consumer.zh.md: c80ecba3ba427cb2fbf898f14c2253b10f1f68c2
|
||||
@@ -16,7 +16,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
|
||||
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
|
||||
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:288`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
|
||||
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
|
||||
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
|
||||
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
|
||||
@@ -62,10 +62,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:173`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:147`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:164`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:174`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:154`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:165`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
|
||||
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `browser-use-runtime`, [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
|
||||
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:208`](../packages/core/tools/src/index.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
@@ -82,6 +82,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
|
||||
| `workspace/session-activity` | `waterfall` | [`packages/workspace/workspace/src/index.ts:129`](../packages/workspace/workspace/src/index.ts) | [`workspace`](../packages/workspace/workspace) (`waterfall`) | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent) |
|
||||
| `workspace/session-stop` | `parallel` | [`packages/workspace/workspace/src/index.ts:147`](../packages/workspace/workspace/src/index.ts) | [`workspace`](../packages/workspace/workspace) (`parallel`) | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent) |
|
||||
|
||||
## Non-harness or undeclared event strings seen in package source
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
|
||||
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
|
||||
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:288`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
|
||||
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
|
||||
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
|
||||
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
|
||||
@@ -64,10 +64,10 @@
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:173`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:147`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:164`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:174`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:154`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:165`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
|
||||
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `browser-use-runtime`, [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
|
||||
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:208`](../packages/core/tools/src/index.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
@@ -84,6 +84,8 @@
|
||||
| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
|
||||
| `workspace/session-activity` | `waterfall` | [`packages/workspace/workspace/src/index.ts:129`](../packages/workspace/workspace/src/index.ts) | [`workspace`](../packages/workspace/workspace) (`waterfall`) | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent) |
|
||||
| `workspace/session-stop` | `parallel` | [`packages/workspace/workspace/src/index.ts:147`](../packages/workspace/workspace/src/index.ts) | [`workspace`](../packages/workspace/workspace) (`parallel`) | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent) |
|
||||
|
||||
## Non-harness or undeclared event strings seen in package source
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/module-graph.md
|
||||
module-graph.md: e36487734f39545fee9b188d9443336ad7ed2e0e
|
||||
module-graph.zh.md: 646aa21ea348ddf5e73d12b94247319cfe3768d6
|
||||
module-graph.md: 8b277a4735da4a86ad32060e46ea67810a50dc7d
|
||||
module-graph.zh.md: 4183ba53d650cb41c70215eeef65a05aaf02a4f6
|
||||
+62
-58
@@ -502,14 +502,6 @@ flowchart TD
|
||||
pkg_settings --> pkg_session
|
||||
pkg_session_snapshot --> pkg_http_proxy
|
||||
pkg_session_snapshot --> pkg_session
|
||||
pkg_agent --> pkg_invariants
|
||||
pkg_agent --> pkg_llm
|
||||
pkg_agent --> pkg_scope
|
||||
pkg_agent --> pkg_session
|
||||
pkg_agent --> pkg_session_projection
|
||||
pkg_agent --> pkg_system_prompt
|
||||
pkg_agent --> pkg_typert_protocol
|
||||
pkg_agent --> pkg_util_values
|
||||
pkg_fs --> pkg_brand
|
||||
pkg_fs --> pkg_invariants
|
||||
pkg_fs --> pkg_llm
|
||||
@@ -560,6 +552,51 @@ flowchart TD
|
||||
pkg_llm_pi_ai --> pkg_launch_environment
|
||||
pkg_llm_pi_ai --> pkg_llm
|
||||
pkg_llm_pi_ai --> pkg_timeout
|
||||
pkg_agent --> pkg_invariants
|
||||
pkg_agent --> pkg_llm
|
||||
pkg_agent --> pkg_scope
|
||||
pkg_agent --> pkg_session
|
||||
pkg_agent --> pkg_session_projection
|
||||
pkg_agent --> pkg_system_prompt
|
||||
pkg_agent --> pkg_typert_protocol
|
||||
pkg_agent --> pkg_util_values
|
||||
pkg_agent --> pkg_workspace
|
||||
pkg_fs_local --> pkg_fs
|
||||
pkg_fs_observation_policy --> pkg_fs
|
||||
pkg_skill_filesystem --> pkg_fs
|
||||
pkg_skill_filesystem --> pkg_home_paths
|
||||
pkg_skill_filesystem --> pkg_skill
|
||||
pkg_hook_protocol --> pkg_invariants
|
||||
pkg_hook_protocol --> pkg_session
|
||||
pkg_hook_protocol --> pkg_shell
|
||||
pkg_api_workspace_controller --> pkg_api_gateway
|
||||
pkg_api_workspace_controller --> pkg_client_connection
|
||||
pkg_api_workspace_controller --> pkg_host_directory_picker
|
||||
pkg_api_workspace_controller --> pkg_session
|
||||
pkg_api_workspace_controller --> pkg_storage_domain
|
||||
pkg_api_workspace_controller --> pkg_typert_protocol
|
||||
pkg_api_workspace_controller --> pkg_workspace
|
||||
pkg_config_editor --> pkg_app_boot
|
||||
pkg_config_editor --> pkg_hmr
|
||||
pkg_experimental_api_speech_to_text --> pkg_experimental_speech_to_text
|
||||
pkg_experimental_api_speech_to_text --> pkg_typert_protocol
|
||||
pkg_experimental_ptc_runtime_python --> pkg_ptc_runtime
|
||||
pkg_experimental_ptc_runtime_python --> pkg_timeout
|
||||
pkg_experimental_ptc_runtime_python --> pkg_util_values
|
||||
pkg_experimental_speech_to_text_sensevoice --> pkg_experimental_speech_to_text
|
||||
pkg_experimental_speech_to_text_sensevoice --> pkg_subprocess
|
||||
pkg_lsp_stdio --> pkg_brand
|
||||
pkg_lsp_stdio --> pkg_fs
|
||||
pkg_lsp_stdio --> pkg_llm
|
||||
pkg_lsp_stdio --> pkg_lsp
|
||||
pkg_lsp_stdio --> pkg_subprocess
|
||||
pkg_lsp_stdio --> pkg_timeout
|
||||
pkg_bash_local --> pkg_shell
|
||||
pkg_bash_local --> pkg_subprocess
|
||||
pkg_bash_local --> pkg_timeout
|
||||
pkg_pwsh_local --> pkg_shell
|
||||
pkg_pwsh_local --> pkg_subprocess
|
||||
pkg_pwsh_local --> pkg_timeout
|
||||
pkg_llm_retry --> pkg_agent
|
||||
pkg_llm_retry --> pkg_brand
|
||||
pkg_llm_retry --> pkg_invariants
|
||||
@@ -577,28 +614,11 @@ flowchart TD
|
||||
pkg_goal --> pkg_session
|
||||
pkg_goal --> pkg_session_projection
|
||||
pkg_goal --> pkg_typert_protocol
|
||||
pkg_fs_local --> pkg_fs
|
||||
pkg_fs_observation_policy --> pkg_fs
|
||||
pkg_skill_filesystem --> pkg_fs
|
||||
pkg_skill_filesystem --> pkg_home_paths
|
||||
pkg_skill_filesystem --> pkg_skill
|
||||
pkg_web_search_deepseek --> pkg_agent
|
||||
pkg_web_search_deepseek --> pkg_credentials
|
||||
pkg_web_search_deepseek --> pkg_launch_environment
|
||||
pkg_web_search_deepseek --> pkg_session
|
||||
pkg_web_search_deepseek --> pkg_web
|
||||
pkg_hook_protocol --> pkg_invariants
|
||||
pkg_hook_protocol --> pkg_session
|
||||
pkg_hook_protocol --> pkg_shell
|
||||
pkg_api_workspace_controller --> pkg_api_gateway
|
||||
pkg_api_workspace_controller --> pkg_client_connection
|
||||
pkg_api_workspace_controller --> pkg_host_directory_picker
|
||||
pkg_api_workspace_controller --> pkg_session
|
||||
pkg_api_workspace_controller --> pkg_storage_domain
|
||||
pkg_api_workspace_controller --> pkg_typert_protocol
|
||||
pkg_api_workspace_controller --> pkg_workspace
|
||||
pkg_config_editor --> pkg_app_boot
|
||||
pkg_config_editor --> pkg_hmr
|
||||
pkg_file_reference --> pkg_agent
|
||||
pkg_time_context --> pkg_agent
|
||||
pkg_time_context --> pkg_invariants
|
||||
@@ -609,13 +629,6 @@ flowchart TD
|
||||
pkg_tmux_context --> pkg_session
|
||||
pkg_tmux_context --> pkg_session_projection
|
||||
pkg_tmux_context --> pkg_shell
|
||||
pkg_experimental_api_speech_to_text --> pkg_experimental_speech_to_text
|
||||
pkg_experimental_api_speech_to_text --> pkg_typert_protocol
|
||||
pkg_experimental_ptc_runtime_python --> pkg_ptc_runtime
|
||||
pkg_experimental_ptc_runtime_python --> pkg_timeout
|
||||
pkg_experimental_ptc_runtime_python --> pkg_util_values
|
||||
pkg_experimental_speech_to_text_sensevoice --> pkg_experimental_speech_to_text
|
||||
pkg_experimental_speech_to_text_sensevoice --> pkg_subprocess
|
||||
pkg_commands --> pkg_agent
|
||||
pkg_commands --> pkg_attachment
|
||||
pkg_commands --> pkg_brand
|
||||
@@ -638,12 +651,7 @@ flowchart TD
|
||||
pkg_jobs --> pkg_brand
|
||||
pkg_jobs --> pkg_invariants
|
||||
pkg_jobs --> pkg_session
|
||||
pkg_lsp_stdio --> pkg_brand
|
||||
pkg_lsp_stdio --> pkg_fs
|
||||
pkg_lsp_stdio --> pkg_llm
|
||||
pkg_lsp_stdio --> pkg_lsp
|
||||
pkg_lsp_stdio --> pkg_subprocess
|
||||
pkg_lsp_stdio --> pkg_timeout
|
||||
pkg_jobs --> pkg_workspace
|
||||
pkg_sandbox_policy --> pkg_agent
|
||||
pkg_sandbox_policy --> pkg_invariants
|
||||
pkg_sandbox_policy --> pkg_sandbox
|
||||
@@ -658,12 +666,6 @@ flowchart TD
|
||||
pkg_session_title --> pkg_llm
|
||||
pkg_session_title --> pkg_session
|
||||
pkg_session_title --> pkg_session_projection
|
||||
pkg_bash_local --> pkg_shell
|
||||
pkg_bash_local --> pkg_subprocess
|
||||
pkg_bash_local --> pkg_timeout
|
||||
pkg_pwsh_local --> pkg_shell
|
||||
pkg_pwsh_local --> pkg_subprocess
|
||||
pkg_pwsh_local --> pkg_timeout
|
||||
pkg_terminal --> pkg_agent
|
||||
pkg_terminal --> pkg_brand
|
||||
pkg_loader_smoke --> pkg_agent
|
||||
@@ -912,6 +914,7 @@ flowchart TD
|
||||
pkg_schedule --> pkg_session_persistence
|
||||
pkg_schedule --> pkg_session_projection
|
||||
pkg_schedule --> pkg_tools
|
||||
pkg_schedule --> pkg_workspace
|
||||
pkg_session_checkpoint_policy --> pkg_agent
|
||||
pkg_session_checkpoint_policy --> pkg_llm
|
||||
pkg_session_checkpoint_policy --> pkg_session
|
||||
@@ -1083,6 +1086,7 @@ flowchart TD
|
||||
pkg_subagent --> pkg_typert_protocol
|
||||
pkg_subagent --> pkg_user_approval
|
||||
pkg_subagent --> pkg_util_time
|
||||
pkg_subagent --> pkg_workspace
|
||||
pkg_session_query_sqlite --> pkg_session
|
||||
pkg_session_query_sqlite --> pkg_session_persistence
|
||||
pkg_session_query_sqlite --> pkg_session_query
|
||||
@@ -1477,7 +1481,6 @@ flowchart TD
|
||||
| [`session-projection`](../packages/session/session-projection) | `session` | [`session`](../packages/core/session) |
|
||||
| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
|
||||
| [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`http-proxy`](../packages/util/http-proxy), [`session`](../packages/core/session) |
|
||||
| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values) |
|
||||
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
|
||||
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) |
|
||||
| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
|
||||
@@ -1493,32 +1496,33 @@ flowchart TD
|
||||
| [`workspace`](../packages/workspace/workspace) | `workspace` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) |
|
||||
| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) |
|
||||
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) |
|
||||
| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
|
||||
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) |
|
||||
| [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs) |
|
||||
| [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`skill`](../packages/skill/skill) |
|
||||
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) |
|
||||
| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) |
|
||||
| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`config-editor`](../packages/boot/config-editor) | `boot` | [`app-boot`](../packages/boot/app-boot), [`hmr`](../packages/boot/hmr) |
|
||||
| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) |
|
||||
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
|
||||
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) |
|
||||
| [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | `experimental` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | `experimental` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) |
|
||||
| [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | `experimental` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
|
||||
| [`bash-local`](../packages/shell/bash-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
|
||||
| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
|
||||
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) |
|
||||
| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
|
||||
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) |
|
||||
| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) |
|
||||
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
|
||||
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) |
|
||||
| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
|
||||
| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
|
||||
| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`session`](../packages/core/session) |
|
||||
| [`session-title`](../packages/session/session-title) | `session` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
|
||||
| [`bash-local`](../packages/shell/bash-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
|
||||
| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
|
||||
| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand) |
|
||||
| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`http-proxy`](../packages/util/http-proxy), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
@@ -1567,7 +1571,7 @@ flowchart TD
|
||||
| [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`mcp-resources`](../packages/mcp/mcp-resources) | `mcp` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`agent-preset-registry`](../packages/preset/agent-preset-registry) | `preset` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
|
||||
| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) |
|
||||
| [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
|
||||
| [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
|
||||
@@ -1596,7 +1600,7 @@ flowchart TD
|
||||
| [`subprocess-ssh`](../packages/ssh/subprocess-ssh) | `ssh` | [`ssh`](../packages/ssh/ssh), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) |
|
||||
| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) |
|
||||
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
|
||||
| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
|
||||
|
||||
+62
-58
@@ -504,14 +504,6 @@ flowchart TD
|
||||
pkg_settings --> pkg_session
|
||||
pkg_session_snapshot --> pkg_http_proxy
|
||||
pkg_session_snapshot --> pkg_session
|
||||
pkg_agent --> pkg_invariants
|
||||
pkg_agent --> pkg_llm
|
||||
pkg_agent --> pkg_scope
|
||||
pkg_agent --> pkg_session
|
||||
pkg_agent --> pkg_session_projection
|
||||
pkg_agent --> pkg_system_prompt
|
||||
pkg_agent --> pkg_typert_protocol
|
||||
pkg_agent --> pkg_util_values
|
||||
pkg_fs --> pkg_brand
|
||||
pkg_fs --> pkg_invariants
|
||||
pkg_fs --> pkg_llm
|
||||
@@ -562,6 +554,51 @@ flowchart TD
|
||||
pkg_llm_pi_ai --> pkg_launch_environment
|
||||
pkg_llm_pi_ai --> pkg_llm
|
||||
pkg_llm_pi_ai --> pkg_timeout
|
||||
pkg_agent --> pkg_invariants
|
||||
pkg_agent --> pkg_llm
|
||||
pkg_agent --> pkg_scope
|
||||
pkg_agent --> pkg_session
|
||||
pkg_agent --> pkg_session_projection
|
||||
pkg_agent --> pkg_system_prompt
|
||||
pkg_agent --> pkg_typert_protocol
|
||||
pkg_agent --> pkg_util_values
|
||||
pkg_agent --> pkg_workspace
|
||||
pkg_fs_local --> pkg_fs
|
||||
pkg_fs_observation_policy --> pkg_fs
|
||||
pkg_skill_filesystem --> pkg_fs
|
||||
pkg_skill_filesystem --> pkg_home_paths
|
||||
pkg_skill_filesystem --> pkg_skill
|
||||
pkg_hook_protocol --> pkg_invariants
|
||||
pkg_hook_protocol --> pkg_session
|
||||
pkg_hook_protocol --> pkg_shell
|
||||
pkg_api_workspace_controller --> pkg_api_gateway
|
||||
pkg_api_workspace_controller --> pkg_client_connection
|
||||
pkg_api_workspace_controller --> pkg_host_directory_picker
|
||||
pkg_api_workspace_controller --> pkg_session
|
||||
pkg_api_workspace_controller --> pkg_storage_domain
|
||||
pkg_api_workspace_controller --> pkg_typert_protocol
|
||||
pkg_api_workspace_controller --> pkg_workspace
|
||||
pkg_config_editor --> pkg_app_boot
|
||||
pkg_config_editor --> pkg_hmr
|
||||
pkg_experimental_api_speech_to_text --> pkg_experimental_speech_to_text
|
||||
pkg_experimental_api_speech_to_text --> pkg_typert_protocol
|
||||
pkg_experimental_ptc_runtime_python --> pkg_ptc_runtime
|
||||
pkg_experimental_ptc_runtime_python --> pkg_timeout
|
||||
pkg_experimental_ptc_runtime_python --> pkg_util_values
|
||||
pkg_experimental_speech_to_text_sensevoice --> pkg_experimental_speech_to_text
|
||||
pkg_experimental_speech_to_text_sensevoice --> pkg_subprocess
|
||||
pkg_lsp_stdio --> pkg_brand
|
||||
pkg_lsp_stdio --> pkg_fs
|
||||
pkg_lsp_stdio --> pkg_llm
|
||||
pkg_lsp_stdio --> pkg_lsp
|
||||
pkg_lsp_stdio --> pkg_subprocess
|
||||
pkg_lsp_stdio --> pkg_timeout
|
||||
pkg_bash_local --> pkg_shell
|
||||
pkg_bash_local --> pkg_subprocess
|
||||
pkg_bash_local --> pkg_timeout
|
||||
pkg_pwsh_local --> pkg_shell
|
||||
pkg_pwsh_local --> pkg_subprocess
|
||||
pkg_pwsh_local --> pkg_timeout
|
||||
pkg_llm_retry --> pkg_agent
|
||||
pkg_llm_retry --> pkg_brand
|
||||
pkg_llm_retry --> pkg_invariants
|
||||
@@ -579,28 +616,11 @@ flowchart TD
|
||||
pkg_goal --> pkg_session
|
||||
pkg_goal --> pkg_session_projection
|
||||
pkg_goal --> pkg_typert_protocol
|
||||
pkg_fs_local --> pkg_fs
|
||||
pkg_fs_observation_policy --> pkg_fs
|
||||
pkg_skill_filesystem --> pkg_fs
|
||||
pkg_skill_filesystem --> pkg_home_paths
|
||||
pkg_skill_filesystem --> pkg_skill
|
||||
pkg_web_search_deepseek --> pkg_agent
|
||||
pkg_web_search_deepseek --> pkg_credentials
|
||||
pkg_web_search_deepseek --> pkg_launch_environment
|
||||
pkg_web_search_deepseek --> pkg_session
|
||||
pkg_web_search_deepseek --> pkg_web
|
||||
pkg_hook_protocol --> pkg_invariants
|
||||
pkg_hook_protocol --> pkg_session
|
||||
pkg_hook_protocol --> pkg_shell
|
||||
pkg_api_workspace_controller --> pkg_api_gateway
|
||||
pkg_api_workspace_controller --> pkg_client_connection
|
||||
pkg_api_workspace_controller --> pkg_host_directory_picker
|
||||
pkg_api_workspace_controller --> pkg_session
|
||||
pkg_api_workspace_controller --> pkg_storage_domain
|
||||
pkg_api_workspace_controller --> pkg_typert_protocol
|
||||
pkg_api_workspace_controller --> pkg_workspace
|
||||
pkg_config_editor --> pkg_app_boot
|
||||
pkg_config_editor --> pkg_hmr
|
||||
pkg_file_reference --> pkg_agent
|
||||
pkg_time_context --> pkg_agent
|
||||
pkg_time_context --> pkg_invariants
|
||||
@@ -611,13 +631,6 @@ flowchart TD
|
||||
pkg_tmux_context --> pkg_session
|
||||
pkg_tmux_context --> pkg_session_projection
|
||||
pkg_tmux_context --> pkg_shell
|
||||
pkg_experimental_api_speech_to_text --> pkg_experimental_speech_to_text
|
||||
pkg_experimental_api_speech_to_text --> pkg_typert_protocol
|
||||
pkg_experimental_ptc_runtime_python --> pkg_ptc_runtime
|
||||
pkg_experimental_ptc_runtime_python --> pkg_timeout
|
||||
pkg_experimental_ptc_runtime_python --> pkg_util_values
|
||||
pkg_experimental_speech_to_text_sensevoice --> pkg_experimental_speech_to_text
|
||||
pkg_experimental_speech_to_text_sensevoice --> pkg_subprocess
|
||||
pkg_commands --> pkg_agent
|
||||
pkg_commands --> pkg_attachment
|
||||
pkg_commands --> pkg_brand
|
||||
@@ -640,12 +653,7 @@ flowchart TD
|
||||
pkg_jobs --> pkg_brand
|
||||
pkg_jobs --> pkg_invariants
|
||||
pkg_jobs --> pkg_session
|
||||
pkg_lsp_stdio --> pkg_brand
|
||||
pkg_lsp_stdio --> pkg_fs
|
||||
pkg_lsp_stdio --> pkg_llm
|
||||
pkg_lsp_stdio --> pkg_lsp
|
||||
pkg_lsp_stdio --> pkg_subprocess
|
||||
pkg_lsp_stdio --> pkg_timeout
|
||||
pkg_jobs --> pkg_workspace
|
||||
pkg_sandbox_policy --> pkg_agent
|
||||
pkg_sandbox_policy --> pkg_invariants
|
||||
pkg_sandbox_policy --> pkg_sandbox
|
||||
@@ -660,12 +668,6 @@ flowchart TD
|
||||
pkg_session_title --> pkg_llm
|
||||
pkg_session_title --> pkg_session
|
||||
pkg_session_title --> pkg_session_projection
|
||||
pkg_bash_local --> pkg_shell
|
||||
pkg_bash_local --> pkg_subprocess
|
||||
pkg_bash_local --> pkg_timeout
|
||||
pkg_pwsh_local --> pkg_shell
|
||||
pkg_pwsh_local --> pkg_subprocess
|
||||
pkg_pwsh_local --> pkg_timeout
|
||||
pkg_terminal --> pkg_agent
|
||||
pkg_terminal --> pkg_brand
|
||||
pkg_loader_smoke --> pkg_agent
|
||||
@@ -914,6 +916,7 @@ flowchart TD
|
||||
pkg_schedule --> pkg_session_persistence
|
||||
pkg_schedule --> pkg_session_projection
|
||||
pkg_schedule --> pkg_tools
|
||||
pkg_schedule --> pkg_workspace
|
||||
pkg_session_checkpoint_policy --> pkg_agent
|
||||
pkg_session_checkpoint_policy --> pkg_llm
|
||||
pkg_session_checkpoint_policy --> pkg_session
|
||||
@@ -1085,6 +1088,7 @@ flowchart TD
|
||||
pkg_subagent --> pkg_typert_protocol
|
||||
pkg_subagent --> pkg_user_approval
|
||||
pkg_subagent --> pkg_util_time
|
||||
pkg_subagent --> pkg_workspace
|
||||
pkg_session_query_sqlite --> pkg_session
|
||||
pkg_session_query_sqlite --> pkg_session_persistence
|
||||
pkg_session_query_sqlite --> pkg_session_query
|
||||
@@ -1479,7 +1483,6 @@ flowchart TD
|
||||
| [`session-projection`](../packages/session/session-projection) | `session` | [`session`](../packages/core/session) |
|
||||
| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
|
||||
| [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`http-proxy`](../packages/util/http-proxy), [`session`](../packages/core/session) |
|
||||
| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values) |
|
||||
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
|
||||
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) |
|
||||
| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
|
||||
@@ -1495,32 +1498,33 @@ flowchart TD
|
||||
| [`workspace`](../packages/workspace/workspace) | `workspace` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) |
|
||||
| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) |
|
||||
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) |
|
||||
| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
|
||||
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) |
|
||||
| [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs) |
|
||||
| [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`skill`](../packages/skill/skill) |
|
||||
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) |
|
||||
| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) |
|
||||
| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`config-editor`](../packages/boot/config-editor) | `boot` | [`app-boot`](../packages/boot/app-boot), [`hmr`](../packages/boot/hmr) |
|
||||
| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) |
|
||||
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
|
||||
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) |
|
||||
| [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | `experimental` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | `experimental` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) |
|
||||
| [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | `experimental` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
|
||||
| [`bash-local`](../packages/shell/bash-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
|
||||
| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
|
||||
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) |
|
||||
| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
|
||||
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) |
|
||||
| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) |
|
||||
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
|
||||
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) |
|
||||
| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
|
||||
| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
|
||||
| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`session`](../packages/core/session) |
|
||||
| [`session-title`](../packages/session/session-title) | `session` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
|
||||
| [`bash-local`](../packages/shell/bash-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
|
||||
| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
|
||||
| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand) |
|
||||
| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`http-proxy`](../packages/util/http-proxy), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
@@ -1569,7 +1573,7 @@ flowchart TD
|
||||
| [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`mcp-resources`](../packages/mcp/mcp-resources) | `mcp` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`agent-preset-registry`](../packages/preset/agent-preset-registry) | `preset` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
|
||||
| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) |
|
||||
| [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
|
||||
| [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
|
||||
@@ -1598,7 +1602,7 @@ flowchart TD
|
||||
| [`subprocess-ssh`](../packages/ssh/subprocess-ssh) | `ssh` | [`ssh`](../packages/ssh/ssh), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) |
|
||||
| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) |
|
||||
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
|
||||
| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/persistence-catalog.md
|
||||
persistence-catalog.md: d771378c6899d2c55c30407a9e87ebe3689f9838
|
||||
persistence-catalog.zh.md: 2ca7c9940efdbeb8309010891025d5bd73030da3
|
||||
persistence-catalog.md: bb3ee2c998189533ed87e149422eae8123fb07b1
|
||||
persistence-catalog.zh.md: 3dabca40d2975e090935d471cd187def826ea4df
|
||||
+14
-14
@@ -180,7 +180,7 @@ Sources: [`packages/core/session/src/types.ts:431`](../packages/core/session/src
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:87`](../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:96`](../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent-preset/*`
|
||||
|
||||
@@ -775,7 +775,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:219`](../packages/schedule/schedule/src/types.ts)
|
||||
Source: [`packages/schedule/schedule/src/types.ts:228`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
### `session/*`
|
||||
|
||||
@@ -2917,7 +2917,7 @@ SHA-256: `af6e173f7be819bcd0a5c8d77918649c10ce761d8a184ff472f2e01ce64eb2e0`
|
||||
|
||||
SHA-256: `27e337d3d6b402b221e50c247a3f518a70d0c474cc60beee15fac3e9c5cbfcbe`
|
||||
|
||||
Sources: [`packages/schedule/schedule/src/types.ts:13`](../packages/schedule/schedule/src/types.ts)
|
||||
Sources: [`packages/schedule/schedule/src/types.ts:15`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
@@ -3084,7 +3084,7 @@ Array of [`AssistantStreamRecord`](#persistence-type-sha256-33341aac09312300de5a
|
||||
|
||||
SHA-256: `658d895d187b26d83f3b53109a3bf77bbfba9f6f9a6d4bc4ee4315b4eb36239e`
|
||||
|
||||
Sources: [`packages/schedule/schedule/src/types.ts:27`](../packages/schedule/schedule/src/types.ts)
|
||||
Sources: [`packages/schedule/schedule/src/types.ts:29`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
@@ -3298,7 +3298,7 @@ Sources: [`packages/core/session/src/types.ts:240`](../packages/core/session/src
|
||||
|
||||
SHA-256: `e6063d0451dc02026fd9b54ad3f77c7b1b6392ad3e872a7737ac84e13ca72344`
|
||||
|
||||
Sources: [`packages/schedule/schedule/src/types.ts:93`](../packages/schedule/schedule/src/types.ts)
|
||||
Sources: [`packages/schedule/schedule/src/types.ts:95`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
@@ -3317,7 +3317,7 @@ Sources: [`packages/schedule/schedule/src/types.ts:93`](../packages/schedule/sch
|
||||
|
||||
SHA-256: `66ece68282d8c06330fb83329a24ae54c386d5ccfab296bbf18369da8beb7d14`
|
||||
|
||||
Sources: [`packages/schedule/schedule/src/types.ts:39`](../packages/schedule/schedule/src/types.ts)
|
||||
Sources: [`packages/schedule/schedule/src/types.ts:41`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
@@ -3689,7 +3689,7 @@ Array of [`ImageOffloadTarget`](#persistence-type-sha256-1cde1bedde79c5e8a56aee9
|
||||
|
||||
SHA-256: `96f4a9c81fc0f940ef71528a8cc66731ae4cf1f40d79680c5fe3c39f96d7723c`
|
||||
|
||||
Sources: [`packages/core/agent/src/types.ts:30`](../packages/core/agent/src/types.ts)
|
||||
Sources: [`packages/core/agent/src/types.ts:39`](../packages/core/agent/src/types.ts)
|
||||
|
||||
One of:
|
||||
|
||||
@@ -4092,7 +4092,7 @@ Sources: [`packages/api/session-controller/src/types.ts:100`](../packages/api/se
|
||||
|
||||
SHA-256: `8e8e91646a5cae3fc78f7532a0013ff2088ae47077642c2edd507a508209b8af`
|
||||
|
||||
Sources: [`packages/schedule/schedule/src/types.ts:86`](../packages/schedule/schedule/src/types.ts)
|
||||
Sources: [`packages/schedule/schedule/src/types.ts:88`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
@@ -4304,7 +4304,7 @@ One of:
|
||||
|
||||
SHA-256: `e1d0e52dcd1855ffb803efc18f8f7b776c48d1c25757e8a75c0281fa298c02e1`
|
||||
|
||||
Sources: [`packages/schedule/schedule/src/types.ts:105`](../packages/schedule/schedule/src/types.ts)
|
||||
Sources: [`packages/schedule/schedule/src/types.ts:107`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
One of:
|
||||
|
||||
@@ -4323,7 +4323,7 @@ One of:
|
||||
|
||||
SHA-256: `ca6425edd20afe34d0cedc6ad1abad1ad7b32e2fbc4c1d24db53939e80bf887f`
|
||||
|
||||
Sources: [`packages/schedule/schedule/src/types.ts:72`](../packages/schedule/schedule/src/types.ts)
|
||||
Sources: [`packages/schedule/schedule/src/types.ts:74`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
@@ -4341,7 +4341,7 @@ Sources: [`packages/schedule/schedule/src/types.ts:72`](../packages/schedule/sch
|
||||
|
||||
SHA-256: `18dc21f12bbac8ade8377e6fbb54179e20c3d955d73fba3244c57fb6bf001df0`
|
||||
|
||||
Sources: [`packages/schedule/schedule/src/types.ts:79`](../packages/schedule/schedule/src/types.ts)
|
||||
Sources: [`packages/schedule/schedule/src/types.ts:81`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
@@ -4359,7 +4359,7 @@ Sources: [`packages/schedule/schedule/src/types.ts:79`](../packages/schedule/sch
|
||||
|
||||
SHA-256: `d8f868c6b3e7189de6c1ffe2ea04794ea2aa31315b6347d12536e46020afac87`
|
||||
|
||||
Sources: [`packages/schedule/schedule/src/types.ts:69`](../packages/schedule/schedule/src/types.ts)
|
||||
Sources: [`packages/schedule/schedule/src/types.ts:71`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
| kind | Form property | Other required fields | Full definition |
|
||||
|---|---|---|---|
|
||||
@@ -5315,7 +5315,7 @@ Array of `number`.
|
||||
|
||||
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
|
||||
|
||||
Sources: [`packages/api/session-controller/src/types.ts:396`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
|
||||
Sources: [`packages/api/session-controller/src/types.ts:396`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:12`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
|
||||
|
||||
`string`
|
||||
|
||||
@@ -6071,7 +6071,7 @@ Sources: [`packages/core/session/src/types.ts:427`](../packages/core/session/src
|
||||
|
||||
SHA-256: `dc4bd7ac827f4b1f9236f9a157a4b4cc171a0588064a611e43edd95f62c68130`
|
||||
|
||||
Sources: [`packages/core/agent/src/types.ts:87`](../packages/core/agent/src/types.ts)
|
||||
Sources: [`packages/core/agent/src/types.ts:96`](../packages/core/agent/src/types.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
|
||||
@@ -182,7 +182,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
}
|
||||
```
|
||||
|
||||
来源:[`packages/core/agent/src/types.ts:87`](../packages/core/agent/src/types.ts)
|
||||
来源:[`packages/core/agent/src/types.ts:96`](../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent-preset/*`
|
||||
|
||||
@@ -777,7 +777,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
|
||||
类型:[ScheduleChange](subsystems/schedule.zh.md)
|
||||
|
||||
来源:[`packages/schedule/schedule/src/types.ts:219`](../packages/schedule/schedule/src/types.ts)
|
||||
来源:[`packages/schedule/schedule/src/types.ts:228`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
### `session/*`
|
||||
|
||||
@@ -2919,7 +2919,7 @@ SHA-256: `af6e173f7be819bcd0a5c8d77918649c10ce761d8a184ff472f2e01ce64eb2e0`
|
||||
|
||||
SHA-256: `27e337d3d6b402b221e50c247a3f518a70d0c474cc60beee15fac3e9c5cbfcbe`
|
||||
|
||||
来源:[`packages/schedule/schedule/src/types.ts:13`](../packages/schedule/schedule/src/types.ts)
|
||||
来源:[`packages/schedule/schedule/src/types.ts:15`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
@@ -3086,7 +3086,7 @@ SHA-256: `84bbfdce7d2eab5b0c53e72ff7406db856c8bfd621b965b72fd86598ba493bf7`
|
||||
|
||||
SHA-256: `658d895d187b26d83f3b53109a3bf77bbfba9f6f9a6d4bc4ee4315b4eb36239e`
|
||||
|
||||
来源:[`packages/schedule/schedule/src/types.ts:27`](../packages/schedule/schedule/src/types.ts)
|
||||
来源:[`packages/schedule/schedule/src/types.ts:29`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
@@ -3300,7 +3300,7 @@ SHA-256: `9a930283b91d493d6347146f2475bb107294b379415ca02162354f46a64164f7`
|
||||
|
||||
SHA-256: `e6063d0451dc02026fd9b54ad3f77c7b1b6392ad3e872a7737ac84e13ca72344`
|
||||
|
||||
来源:[`packages/schedule/schedule/src/types.ts:93`](../packages/schedule/schedule/src/types.ts)
|
||||
来源:[`packages/schedule/schedule/src/types.ts:95`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
@@ -3319,7 +3319,7 @@ SHA-256: `e6063d0451dc02026fd9b54ad3f77c7b1b6392ad3e872a7737ac84e13ca72344`
|
||||
|
||||
SHA-256: `66ece68282d8c06330fb83329a24ae54c386d5ccfab296bbf18369da8beb7d14`
|
||||
|
||||
来源:[`packages/schedule/schedule/src/types.ts:39`](../packages/schedule/schedule/src/types.ts)
|
||||
来源:[`packages/schedule/schedule/src/types.ts:41`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
@@ -3691,7 +3691,7 @@ SHA-256: `9e2c06d8653585b33d72d5c1a80da79c8a24a88c284409bca15cc0f0ead5ebfb`
|
||||
|
||||
SHA-256: `96f4a9c81fc0f940ef71528a8cc66731ae4cf1f40d79680c5fe3c39f96d7723c`
|
||||
|
||||
来源:[`packages/core/agent/src/types.ts:30`](../packages/core/agent/src/types.ts)
|
||||
来源:[`packages/core/agent/src/types.ts:39`](../packages/core/agent/src/types.ts)
|
||||
|
||||
以下类型之一:
|
||||
|
||||
@@ -4094,7 +4094,7 @@ SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b`
|
||||
|
||||
SHA-256: `8e8e91646a5cae3fc78f7532a0013ff2088ae47077642c2edd507a508209b8af`
|
||||
|
||||
来源:[`packages/schedule/schedule/src/types.ts:86`](../packages/schedule/schedule/src/types.ts)
|
||||
来源:[`packages/schedule/schedule/src/types.ts:88`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
@@ -4306,7 +4306,7 @@ SHA-256: `fab2bb09bc5e3b9db0685e8f5500b029af1a2b33e70bc703c1d5d0f596fbc1fd`
|
||||
|
||||
SHA-256: `e1d0e52dcd1855ffb803efc18f8f7b776c48d1c25757e8a75c0281fa298c02e1`
|
||||
|
||||
来源:[`packages/schedule/schedule/src/types.ts:105`](../packages/schedule/schedule/src/types.ts)
|
||||
来源:[`packages/schedule/schedule/src/types.ts:107`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
以下类型之一:
|
||||
|
||||
@@ -4325,7 +4325,7 @@ SHA-256: `e1d0e52dcd1855ffb803efc18f8f7b776c48d1c25757e8a75c0281fa298c02e1`
|
||||
|
||||
SHA-256: `ca6425edd20afe34d0cedc6ad1abad1ad7b32e2fbc4c1d24db53939e80bf887f`
|
||||
|
||||
来源:[`packages/schedule/schedule/src/types.ts:72`](../packages/schedule/schedule/src/types.ts)
|
||||
来源:[`packages/schedule/schedule/src/types.ts:74`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
@@ -4343,7 +4343,7 @@ SHA-256: `ca6425edd20afe34d0cedc6ad1abad1ad7b32e2fbc4c1d24db53939e80bf887f`
|
||||
|
||||
SHA-256: `18dc21f12bbac8ade8377e6fbb54179e20c3d955d73fba3244c57fb6bf001df0`
|
||||
|
||||
来源:[`packages/schedule/schedule/src/types.ts:79`](../packages/schedule/schedule/src/types.ts)
|
||||
来源:[`packages/schedule/schedule/src/types.ts:81`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
@@ -4361,7 +4361,7 @@ SHA-256: `18dc21f12bbac8ade8377e6fbb54179e20c3d955d73fba3244c57fb6bf001df0`
|
||||
|
||||
SHA-256: `d8f868c6b3e7189de6c1ffe2ea04794ea2aa31315b6347d12536e46020afac87`
|
||||
|
||||
来源:[`packages/schedule/schedule/src/types.ts:69`](../packages/schedule/schedule/src/types.ts)
|
||||
来源:[`packages/schedule/schedule/src/types.ts:71`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
| kind | form 属性 | 其他必需字段 | 完整定义 |
|
||||
|---|---|---|---|
|
||||
@@ -5317,7 +5317,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78`
|
||||
|
||||
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
|
||||
|
||||
来源:[`packages/api/session-controller/src/types.ts:396`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
|
||||
来源:[`packages/api/session-controller/src/types.ts:396`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:12`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
|
||||
|
||||
`string`
|
||||
|
||||
@@ -6073,7 +6073,7 @@ SHA-256: `17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3`
|
||||
|
||||
SHA-256: `dc4bd7ac827f4b1f9236f9a157a4b4cc171a0588064a611e43edd95f62c68130`
|
||||
|
||||
来源:[`packages/core/agent/src/types.ts:87`](../packages/core/agent/src/types.ts)
|
||||
来源:[`packages/core/agent/src/types.ts:96`](../packages/core/agent/src/types.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
|
||||
Generated
+12
-12
@@ -29438,7 +29438,7 @@
|
||||
"packages/schedule/schedule/src/types.ts#ScheduleDeleteChange"
|
||||
],
|
||||
"sources": [
|
||||
"packages/schedule/schedule/src/types.ts:79"
|
||||
"packages/schedule/schedule/src/types.ts:81"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -32684,7 +32684,7 @@
|
||||
"packages/schedule/schedule/src/types.ts#AfterScheduleRecord"
|
||||
],
|
||||
"sources": [
|
||||
"packages/schedule/schedule/src/types.ts:13"
|
||||
"packages/schedule/schedule/src/types.ts:15"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -44624,7 +44624,7 @@
|
||||
"packages/llm/llm/src/brand.ts:31",
|
||||
"packages/llm/llm/src/brand.ts:43",
|
||||
"packages/llm/llm/src/brand.ts:67",
|
||||
"packages/schedule/schedule/src/types.ts:10",
|
||||
"packages/schedule/schedule/src/types.ts:12",
|
||||
"packages/session/session-title/src/index.ts:42",
|
||||
"packages/session/session-title/src/types.ts:17",
|
||||
"packages/webhook/webhook/src/brand.ts:12",
|
||||
@@ -54375,7 +54375,7 @@
|
||||
"packages/schedule/schedule/src/types.ts#AtScheduleRecord"
|
||||
],
|
||||
"sources": [
|
||||
"packages/schedule/schedule/src/types.ts:27"
|
||||
"packages/schedule/schedule/src/types.ts:29"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -54617,7 +54617,7 @@
|
||||
"packages/schedule/schedule/src/types.ts#EveryScheduleRecord"
|
||||
],
|
||||
"sources": [
|
||||
"packages/schedule/schedule/src/types.ts:39"
|
||||
"packages/schedule/schedule/src/types.ts:41"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -63758,7 +63758,7 @@
|
||||
"packages/schedule/schedule/src/types.ts#OneShotScheduleDispatchChange"
|
||||
],
|
||||
"sources": [
|
||||
"packages/schedule/schedule/src/types.ts:86"
|
||||
"packages/schedule/schedule/src/types.ts:88"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -64330,7 +64330,7 @@
|
||||
"packages/core/agent/src/types.ts#InboxTarget"
|
||||
],
|
||||
"sources": [
|
||||
"packages/core/agent/src/types.ts:30"
|
||||
"packages/core/agent/src/types.ts:39"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -74172,7 +74172,7 @@
|
||||
"packages/schedule/schedule/src/types.ts#ScheduleCreateChange"
|
||||
],
|
||||
"sources": [
|
||||
"packages/schedule/schedule/src/types.ts:72"
|
||||
"packages/schedule/schedule/src/types.ts:74"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -78164,7 +78164,7 @@
|
||||
"packages/schedule/schedule/src/types.ts#ScheduleRecord"
|
||||
],
|
||||
"sources": [
|
||||
"packages/schedule/schedule/src/types.ts:69"
|
||||
"packages/schedule/schedule/src/types.ts:71"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -81193,7 +81193,7 @@
|
||||
},
|
||||
"names": [],
|
||||
"sources": [
|
||||
"packages/core/agent/src/types.ts:87"
|
||||
"packages/core/agent/src/types.ts:96"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -81796,7 +81796,7 @@
|
||||
"packages/schedule/schedule/src/types.ts#ScheduleChange"
|
||||
],
|
||||
"sources": [
|
||||
"packages/schedule/schedule/src/types.ts:105"
|
||||
"packages/schedule/schedule/src/types.ts:107"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -82985,7 +82985,7 @@
|
||||
"packages/schedule/schedule/src/types.ts#EveryScheduleDispatchChange"
|
||||
],
|
||||
"sources": [
|
||||
"packages/schedule/schedule/src/types.ts:93"
|
||||
"packages/schedule/schedule/src/types.ts:95"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/office-to-pdf.md
|
||||
office-to-pdf.md: dd0f6392f264775f12c0f7612656f1c07366567d
|
||||
office-to-pdf.zh.md: 5c99f87681dd8d98da3717a311a7f858d4b52b32
|
||||
office-to-pdf.md: a01478df546a8873126abbf0efcfd19f9aeb2a09
|
||||
office-to-pdf.zh.md: b81468f13011a8aaa117ae866d17f6184c5902b2
|
||||
@@ -29,9 +29,9 @@ The provider admits the deferred read before allocating source bytes, shares con
|
||||
|
||||
## Preview reads
|
||||
|
||||
`RenderedDocumentBytes` carries workspace file metadata, base64 PDF `data`, `missingFonts`, and `generation`; the original source identity accompanies the converted PDF.
|
||||
`RenderedDocumentBytes` carries workspace file metadata, native PDF `data`, `missingFonts`, and `generation`; the original source identity accompanies the converted PDF.
|
||||
|
||||
The `officeToPdf.render` Remote method checks source authorization and versions through the Session's [Workspace Files](../../packages/api/workspace-files/README.md) service. After conversion admission, `fs.readBytes` supplies raw input within the reserved byte capacity; Office input limits govern this read. The response carries base64 PDF bytes with the source absolute path and freshness version. Source access failures pass through; size and engine failures expose a classified reason without diagnostics. Conversion does not activate an Agent or append events.
|
||||
The `officeToPdf.render` Remote method checks source authorization and versions through the Session's [Workspace Files](../../packages/api/workspace-files/README.md) service. After conversion admission, `fs.readBytes` supplies raw input within the reserved byte capacity; Office input limits govern this read. The binary Remote projects the PDF into a multipart attachment and restores an `ArrayBuffer`-backed `Uint8Array` on the Client. Source access failures pass through; size and engine failures expose a classified reason without diagnostics. Conversion does not activate an Agent or append events.
|
||||
|
||||
The `api/remotes` assembly mounts the conversion service's generated Remote descriptor. The shared Document Preview package registers Office formats with complete-byte loading and its existing PDF.js Worker. Each preview read rechecks renderer generation, source authorization, and version before sharing an in-flight conversion or cached PDF. Connection resets and plugin disposal cancel requests and clear cached bytes. Missing services show localized configuration guidance.
|
||||
|
||||
@@ -71,7 +71,7 @@ convert(request: OfficeToPdfRequest, signal?: AbortSignal): Promise<OfficeToPdfR
|
||||
* @param path - absolute or workspace-relative Office path.
|
||||
* @param priority - foreground preview or speculative background work.
|
||||
* @param signal - Remote cancellation; disposal also cancels outstanding reads and conversions.
|
||||
* @returns complete base64 PDF with original source identity and missing font families.
|
||||
* @returns complete PDF bytes with original source identity and missing font families.
|
||||
*/
|
||||
@Remote async render( workspaceFileScope: WorkspaceFileScope, path: string, priority: OfficeToPdfPriority, signal: AbortSignal, ): Promise<RenderedDocumentBytes>
|
||||
|
||||
|
||||
@@ -29,9 +29,9 @@
|
||||
|
||||
## 预览读取
|
||||
|
||||
`RenderedDocumentBytes` 携带工作区文件元数据、base64 PDF `data`、`missingFonts` 和 `generation`;转换后的 PDF 附带原始源文件身份。
|
||||
`RenderedDocumentBytes` 携带工作区文件元数据、原生 PDF `data`、`missingFonts` 和 `generation`;转换后的 PDF 附带原始源文件身份。
|
||||
|
||||
`officeToPdf.render` Remote 方法通过 Session 的[工作区文件](../../packages/api/workspace-files/README.zh.md)服务检查源文件授权与版本。取得转换容量后,`fs.readBytes` 在预留字节容量内提供原始输入;该读取受 Office 输入上限约束。响应携带 base64 PDF 字节、源文件绝对路径与新鲜度版本。源访问失败直接传递;大小和引擎失败只暴露分类原因,不含诊断信息。转换不激活 Agent 或追加事件。
|
||||
`officeToPdf.render` Remote 方法通过 Session 的[工作区文件](../../packages/api/workspace-files/README.zh.md)服务检查源文件授权与版本。取得转换容量后,`fs.readBytes` 在预留字节容量内提供原始输入;该读取受 Office 输入上限约束。二进制 Remote 将 PDF 投影为 multipart 附件,并在 Client 恢复为由 `ArrayBuffer` 支撑的 `Uint8Array`。源访问失败直接传递;大小和引擎失败只暴露分类原因,不含诊断信息。转换不激活 Agent 或追加事件。
|
||||
|
||||
`api/remotes` 挂载转换服务生成的 Remote 描述符。共享文档预览包使用完整字节加载和现有 PDF.js Worker 注册 Office 格式。每次预览读取都会重新检查渲染 generation、源文件授权和版本,再共享进行中的转换或缓存 PDF。连接重置和插件卸载会取消请求并清空缓存字节。缺少服务时显示本地化配置引导。
|
||||
|
||||
@@ -71,7 +71,7 @@ convert(request: OfficeToPdfRequest, signal?: AbortSignal): Promise<OfficeToPdfR
|
||||
* @param path - absolute or workspace-relative Office path.
|
||||
* @param priority - foreground preview or speculative background work.
|
||||
* @param signal - Remote cancellation; disposal also cancels outstanding reads and conversions.
|
||||
* @returns complete base64 PDF with original source identity and missing font families.
|
||||
* @returns complete PDF bytes with original source identity and missing font families.
|
||||
*/
|
||||
@Remote async render( workspaceFileScope: WorkspaceFileScope, path: string, priority: OfficeToPdfPriority, signal: AbortSignal, ): Promise<RenderedDocumentBytes>
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/workspace.md
|
||||
workspace.md: 8a3369778f0eac10d5fe077686d336eb47f6b41e
|
||||
workspace.zh.md: f1e7e96f1fb5e983de30bd8e2f9fe08ed0635beb
|
||||
workspace.md: 18c1bf3d8b3528a446c4193f33c405d6ae91b7e7
|
||||
workspace.zh.md: c2674737dbc68e994983ce88e7d38aad3e7672ee
|
||||
@@ -129,6 +129,39 @@ The controller's [transport types](../../packages/api/workspace-controller/src/t
|
||||
|
||||
The controller's [transport types](../../packages/api/workspace-controller/src/types.ts) define `WorkspacePinSessionRequest` and `WorkspaceUnpinSessionRequest`, each carrying one `sessionId`. Both operations return `WorkspacePinValue`: the complete `pinnedSessionIds` array of Session ids, most recently pinned first. Pinning requires a known, unarchived Session; unpinning an id that is not pinned succeeds without changing the set. Archiving removes the Session's pin in the same durable write, and unarchiving does not restore it.
|
||||
|
||||
## Archive admission
|
||||
|
||||
Archiving is a registry-global durable set, and the registry refuses to hide running work behind it. The rule is a capability seam over two Host events the package declares and dispatches ([events](#workspace-events)): `workspace/session-activity` (waterfall) asks the composed providers what still runs for a Session, and `workspace/session-stop` (parallel) asks them to stop it. Each provider registers on the root like any listener, so the package knows no agent, job, or schedule vocabulary; the families are keys of a merge-extensible map.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Activity families a `workspace/session-activity` listener may report. This
|
||||
* package declares none: each provider merges its own key from a module both
|
||||
* its Host and Client faces import, so a consumer that renders the families
|
||||
* sees exactly the keys its program compiled and falls through to a generic
|
||||
* description for any other. The shipped providers merge `turn` (the Agent
|
||||
* registry), `job` (the job registry seam), `subagent` (the Subagent
|
||||
* runtime), and `schedule` (the Schedule plugin).
|
||||
*/
|
||||
interface SessionActivityKindMap {}
|
||||
```
|
||||
|
||||
`SessionActivityKind` is `keyof SessionActivityKindMap`, so a program that compiled no provider sees no key at all. The shipped keys live in client-importable type modules: `turn` in the Agent registry's `types.ts`, `job` in the job registry seam's `view.ts`, `subagent` in the Subagent runtime's `control-types.ts`, `schedule` in the Schedule plugin's `types.ts`; a consumer that renders the families imports those modules for its cases and keeps a generic line for any other key. A provider answers the waterfall by prepending its `SessionActivity` entries to the result of `next()`; the registry's innermost callback returns an empty list, so a composition without providers archives freely.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* One reason a session counts as active for archive admission. Families with
|
||||
* per-item identity list their items so a caller can name what must stop.
|
||||
*/
|
||||
interface SessionActivity {
|
||||
readonly kind: SessionActivityKind
|
||||
/** Active items of the family; absent for a family without per-item identity (`turn`). */
|
||||
readonly items?: readonly SessionActivityItem[]
|
||||
}
|
||||
```
|
||||
|
||||
`SessionActivityItem` carries the family-specific `id` (a session, job, or schedule id) and an optional display `label`. `archiveSession(sessionId)` asks the waterfall once, after the existence check, and rejects a non-empty answer with `WorkspaceActiveSessionError` (`sessionId`, `activity`) without writing; the controller maps it to the `workspace/session-active` error, whose details carry the same two fields. `archiveSession(sessionId, { stopActivity: true })` — the `ArchiveSessionOptions` field the transport request exposes as `stopActivity` — skips the check, writes the archive, then dispatches `workspace/session-stop`; a rejecting provider is logged and the archive stays, and the stopped work is never awaited to settlement. An already archived id neither asks nor stops. The shipped providers, what they stop, and the `agent/pre-step` gate that keeps an archived Session from running a model step are documented with the [registry package](../../packages/workspace/workspace/README.md#api-behavior); the decision record is the [archive-stops-running-work Agent Note](../../.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.md).
|
||||
|
||||
## Consumers
|
||||
|
||||
[`dsh-workspace-controller`](../../packages/api/workspace-controller) serves workspace CRUD to GUI clients over `ctx.workspaceRegistry`, and [`dsh-session-controller`](../../packages/api/session-controller) performs the create-session-then-attach flow above. [dsh-agent-instructions](../../packages/context/agent-instructions) is **not** a consumer despite the name: it discovers AGENTS.md-style instruction files under an agent's own cwd and never touches `ctx.workspaceRegistry` — the shared word refers to the user's working directory, not to this registry's entities.
|
||||
@@ -507,13 +540,21 @@ insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise<readonly Workspac
|
||||
/**
|
||||
* Archive one session durably. The session must exist (live or in session
|
||||
* persistence); its workspace accounting — or lack of one — is irrelevant.
|
||||
* Archiving drops the session's pin in the same durable write (pinning and
|
||||
* archival are mutually exclusive). An already archived id resolves without
|
||||
* writing.
|
||||
* Without `stopActivity` the session must also be inactive: the
|
||||
* `workspace/session-activity` waterfall is asked once, and any reported
|
||||
* activity rejects with {@link WorkspaceActiveSessionError} before anything
|
||||
* is written. With `stopActivity` the archive is written without an
|
||||
* activity check, and the `workspace/session-stop` providers are then asked
|
||||
* to stop the session's work: the durable archive set is what a provider's
|
||||
* `agent/pre-step` gate reads, so every wake the stops induce is already
|
||||
* blocked. Archiving drops the session's pin in the same durable write
|
||||
* (pinning and archival are mutually exclusive). An already archived id
|
||||
* resolves without writing, asking, or stopping.
|
||||
* @param sessionId - The session to archive.
|
||||
* @returns resolution after durability.
|
||||
* @param options - Whether running work is stopped instead of refusing.
|
||||
* @returns resolution after durability and, with `stopActivity`, after every stop request was issued.
|
||||
*/
|
||||
archiveSession(sessionId: SessionId): Promise<void>
|
||||
archiveSession(sessionId: SessionId, options: ArchiveSessionOptions = {}): Promise<void>
|
||||
|
||||
/**
|
||||
* Unarchive one session durably by dropping it from the registry-global
|
||||
@@ -558,5 +599,57 @@ async resolveByPath(path: string): Promise<Workspace | undefined>
|
||||
|
||||
Types: [SessionId](core.md)
|
||||
|
||||
Source: [`packages/workspace/workspace/src/index.ts`](../../packages/workspace/workspace/src/index.ts)
|
||||
|
||||
<a id="workspace-events"></a>
|
||||
|
||||
### `workspace/*` events
|
||||
|
||||
<a id="workspacesession-activity--waterfall"></a>
|
||||
|
||||
#### `workspace/session-activity` — waterfall
|
||||
|
||||
Ask the composed providers what still runs for a session before it is archived. A listener prepends its own SessionActivity entries to the result of `next()`; the registry's innermost callback returns an empty list, so a composition without providers archives freely. Any non-empty result refuses the archive without a write.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Ask the composed providers what still runs for a session before it is
|
||||
* archived. A listener prepends its own {@link SessionActivity} entries to
|
||||
* the result of `next()`; the registry's innermost callback returns an
|
||||
* empty list, so a composition without providers archives freely. Any
|
||||
* non-empty result refuses the archive without a write.
|
||||
* @param request - the session about to be archived.
|
||||
* @param next - delegate to the remaining providers.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'workspace/session-activity'( request: SessionActivityRequest, next: () => Promise<readonly SessionActivity[]>, ): Promise<readonly SessionActivity[]>
|
||||
```
|
||||
|
||||
Source: [`packages/workspace/workspace/src/index.ts`](../../packages/workspace/workspace/src/index.ts)
|
||||
|
||||
<a id="workspacesession-stop--parallel"></a>
|
||||
|
||||
#### `workspace/session-stop` — parallel
|
||||
|
||||
Stop a session's running work because the caller archived it with `stopActivity`; the archive set is durable when this dispatches. Each provider stops its own families — cancelling a turn, its subagent descendants, owned jobs, or active schedules — through the same cancel paths the user's own stop actions use, so the session log ends every open turn regularly and a later unarchive can continue the conversation. Listeners issue their stop requests without waiting for running work to settle; a listener may await its own durability barrier. A rejection is logged by the registry and does not undo the archive.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Stop a session's running work because the caller archived it with
|
||||
* `stopActivity`; the archive set is durable when this dispatches. Each
|
||||
* provider stops its own families — cancelling a turn, its subagent
|
||||
* descendants, owned jobs, or active schedules — through the same cancel
|
||||
* paths the user's own stop actions use, so the session log ends every
|
||||
* open turn regularly and a later unarchive can continue the
|
||||
* conversation. Listeners issue their stop requests without waiting for
|
||||
* running work to settle; a listener may await its own durability
|
||||
* barrier. A rejection is logged by the registry and does not undo the
|
||||
* archive.
|
||||
* @param request - the session being archived.
|
||||
* @mode parallel
|
||||
*/
|
||||
'workspace/session-stop'(request: SessionActivityRequest): Promise<void> | void
|
||||
```
|
||||
|
||||
Source: [`packages/workspace/workspace/src/index.ts`](../../packages/workspace/workspace/src/index.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
@@ -129,6 +129,39 @@ interface Workspace {
|
||||
|
||||
控制器的[传输类型](../../packages/api/workspace-controller/src/types.ts)定义了 `WorkspacePinSessionRequest` 和 `WorkspaceUnpinSessionRequest`,两者都携带一个 `sessionId`。两个操作都返回 `WorkspacePinValue`:完整的会话 id 数组 `pinnedSessionIds`,最近置顶的会话排在前面。置顶要求会话已知且未归档;对未置顶的 id 取消置顶会成功,且不改变集合。归档在同一次持久化写入中移除该会话的置顶,取消归档不会恢复置顶。
|
||||
|
||||
## 归档准入
|
||||
|
||||
归档是一个注册表全局的持久化集合,注册表拒绝把正在运行的工作藏在它后面。这条规则是本包声明并派发的两个宿主事件之上的能力接缝([事件](#workspace-events)):`workspace/session-activity`(waterfall)向已组合的提供方询问某会话还有什么在跑,`workspace/session-stop`(parallel)请它们停止这些工作。每个提供方像任何监听器一样在根上注册,因此本包不认识 agent、job 或 schedule 的词汇;各族是一个可合并扩展的 map 的键。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Activity families a `workspace/session-activity` listener may report. This
|
||||
* package declares none: each provider merges its own key from a module both
|
||||
* its Host and Client faces import, so a consumer that renders the families
|
||||
* sees exactly the keys its program compiled and falls through to a generic
|
||||
* description for any other. The shipped providers merge `turn` (the Agent
|
||||
* registry), `job` (the job registry seam), `subagent` (the Subagent
|
||||
* runtime), and `schedule` (the Schedule plugin).
|
||||
*/
|
||||
interface SessionActivityKindMap {}
|
||||
```
|
||||
|
||||
`SessionActivityKind` 即 `keyof SessionActivityKindMap`,因此一个没有编译任何提供方的程序看不到任何键。随附的键放在各自 client 可导入的类型模块里:`turn` 在 Agent 注册表的 `types.ts`,`job` 在任务注册表接缝的 `view.ts`,`subagent` 在 Subagent runtime 的 `control-types.ts`,`schedule` 在 Schedule 插件的 `types.ts`;渲染各族的消费方为其分支导入这些模块,并为其他任何键保留一条通用文案。提供方通过把自己的 `SessionActivity` 条目前置到 `next()` 的结果来回答 waterfall;注册表最内层的回调返回空列表,因此没有提供方的组合可自由归档。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* One reason a session counts as active for archive admission. Families with
|
||||
* per-item identity list their items so a caller can name what must stop.
|
||||
*/
|
||||
interface SessionActivity {
|
||||
readonly kind: SessionActivityKind
|
||||
/** Active items of the family; absent for a family without per-item identity (`turn`). */
|
||||
readonly items?: readonly SessionActivityItem[]
|
||||
}
|
||||
```
|
||||
|
||||
`SessionActivityItem` 携带各族自己的 `id`(会话、任务或提醒 id)和可选的展示 `label`。`archiveSession(sessionId)` 在存在性检查之后只询问 waterfall 一次,对非空答案以 `WorkspaceActiveSessionError`(`sessionId`、`activity`)拒绝且不写入;控制器把它映射为 `workspace/session-active` 错误,其 details 携带同样的两个字段。`archiveSession(sessionId, { stopActivity: true })`——`ArchiveSessionOptions` 的这个字段由传输请求以 `stopActivity` 暴露——跳过检查、先写入归档、再派发 `workspace/session-stop`;提供方抛错只记日志,归档保留,被停止的工作从不等待收敛。已归档的 id 既不询问也不停止。随附的提供方、它们停止什么,以及让已归档会话不跑模型步的 `agent/pre-step` 门禁,记录在[注册表包](../../packages/workspace/workspace/README.zh.md#api-behavior);决策记录见 [archive-stops-running-work Agent Note](../../.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.zh.md)。
|
||||
|
||||
## 消费方
|
||||
|
||||
[`dsh-workspace-controller`](../../packages/api/workspace-controller) 经 `ctx.workspaceRegistry` 向 GUI 客户端提供工作区 CRUD,[`dsh-session-controller`](../../packages/api/session-controller) 执行上文「先建会话再 attach」的流程。[dsh-agent-instructions](../../packages/context/agent-instructions) 尽管名字如此,却**不是**消费方:它在 agent 自己的 cwd 下发现 AGENTS.md 风格的指令文件,从不触碰 `ctx.workspaceRegistry`——两者共用的这个词指的是用户的工作目录,而非本注册表的实体。
|
||||
@@ -507,13 +540,21 @@ insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise<readonly Workspac
|
||||
/**
|
||||
* Archive one session durably. The session must exist (live or in session
|
||||
* persistence); its workspace accounting — or lack of one — is irrelevant.
|
||||
* Archiving drops the session's pin in the same durable write (pinning and
|
||||
* archival are mutually exclusive). An already archived id resolves without
|
||||
* writing.
|
||||
* Without `stopActivity` the session must also be inactive: the
|
||||
* `workspace/session-activity` waterfall is asked once, and any reported
|
||||
* activity rejects with {@link WorkspaceActiveSessionError} before anything
|
||||
* is written. With `stopActivity` the archive is written without an
|
||||
* activity check, and the `workspace/session-stop` providers are then asked
|
||||
* to stop the session's work: the durable archive set is what a provider's
|
||||
* `agent/pre-step` gate reads, so every wake the stops induce is already
|
||||
* blocked. Archiving drops the session's pin in the same durable write
|
||||
* (pinning and archival are mutually exclusive). An already archived id
|
||||
* resolves without writing, asking, or stopping.
|
||||
* @param sessionId - The session to archive.
|
||||
* @returns resolution after durability.
|
||||
* @param options - Whether running work is stopped instead of refusing.
|
||||
* @returns resolution after durability and, with `stopActivity`, after every stop request was issued.
|
||||
*/
|
||||
archiveSession(sessionId: SessionId): Promise<void>
|
||||
archiveSession(sessionId: SessionId, options: ArchiveSessionOptions = {}): Promise<void>
|
||||
|
||||
/**
|
||||
* Unarchive one session durably by dropping it from the registry-global
|
||||
@@ -558,5 +599,57 @@ async resolveByPath(path: string): Promise<Workspace | undefined>
|
||||
|
||||
Types: [SessionId](core.zh.md)
|
||||
|
||||
Source: [`packages/workspace/workspace/src/index.ts`](../../packages/workspace/workspace/src/index.ts)
|
||||
|
||||
<a id="workspace-events"></a>
|
||||
|
||||
### `workspace/*` events
|
||||
|
||||
<a id="workspacesession-activity--waterfall"></a>
|
||||
|
||||
#### `workspace/session-activity` — waterfall
|
||||
|
||||
Ask the composed providers what still runs for a session before it is archived. A listener prepends its own SessionActivity entries to the result of `next()`; the registry's innermost callback returns an empty list, so a composition without providers archives freely. Any non-empty result refuses the archive without a write.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Ask the composed providers what still runs for a session before it is
|
||||
* archived. A listener prepends its own {@link SessionActivity} entries to
|
||||
* the result of `next()`; the registry's innermost callback returns an
|
||||
* empty list, so a composition without providers archives freely. Any
|
||||
* non-empty result refuses the archive without a write.
|
||||
* @param request - the session about to be archived.
|
||||
* @param next - delegate to the remaining providers.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'workspace/session-activity'( request: SessionActivityRequest, next: () => Promise<readonly SessionActivity[]>, ): Promise<readonly SessionActivity[]>
|
||||
```
|
||||
|
||||
Source: [`packages/workspace/workspace/src/index.ts`](../../packages/workspace/workspace/src/index.ts)
|
||||
|
||||
<a id="workspacesession-stop--parallel"></a>
|
||||
|
||||
#### `workspace/session-stop` — parallel
|
||||
|
||||
Stop a session's running work because the caller archived it with `stopActivity`; the archive set is durable when this dispatches. Each provider stops its own families — cancelling a turn, its subagent descendants, owned jobs, or active schedules — through the same cancel paths the user's own stop actions use, so the session log ends every open turn regularly and a later unarchive can continue the conversation. Listeners issue their stop requests without waiting for running work to settle; a listener may await its own durability barrier. A rejection is logged by the registry and does not undo the archive.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Stop a session's running work because the caller archived it with
|
||||
* `stopActivity`; the archive set is durable when this dispatches. Each
|
||||
* provider stops its own families — cancelling a turn, its subagent
|
||||
* descendants, owned jobs, or active schedules — through the same cancel
|
||||
* paths the user's own stop actions use, so the session log ends every
|
||||
* open turn regularly and a later unarchive can continue the
|
||||
* conversation. Listeners issue their stop requests without waiting for
|
||||
* running work to settle; a listener may await its own durability
|
||||
* barrier. A rejection is logged by the registry and does not undo the
|
||||
* archive.
|
||||
* @param request - the session being archived.
|
||||
* @mode parallel
|
||||
*/
|
||||
'workspace/session-stop'(request: SessionActivityRequest): Promise<void> | void
|
||||
```
|
||||
|
||||
Source: [`packages/workspace/workspace/src/index.ts`](../../packages/workspace/workspace/src/index.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
|
||||
README.md: 684616e7e6011c125e8c4dd4b087d551522ab339
|
||||
README.zh.md: 50b37df21b360a02edbbc099a433fc74e07ba3bd
|
||||
README.md: 0fd4ea07c6575a9f75167b294c6da5dab81496de
|
||||
README.zh.md: 8ce6bb6e70a8f2f766c96c2cc103e93348a4d98a
|
||||
@@ -60,6 +60,8 @@ Queue edits replace pending content with non-empty text only.
|
||||
|
||||
Attachment authorization reads declared content fields of built-in Session events and completed assistant stream blocks, including flat V4 tool-role messages. Unknown event payloads and unrelated fields cannot authorize attachment reads.
|
||||
|
||||
The controller composes `ArchivedSessionGate` through `ctx.plugin`: it loads once the Agent registry, Session store, and Workspace registry exist and unwinds with the controller. Its `agent/pre-step` listener rejects a step proposed for an archived Session or for a subagent descendant of one — read from the Session header's lineage fields, never a fork — so a late waking delivery ends its turn as `blocked` without a model request; unarchiving lifts the gate for the whole lineage. The work an archived Session still runs is reported and stopped by its owners through the Workspace registry's archive admission ([seam](../../workspace/workspace/README.md)): the running turn by the [Agent registry](../../core/agent/README.md), owned jobs by the [job registry seam](../../jobs/jobs/README.md), subagent descendants by the [Subagent](../../subagent/subagent/README.md) runtime, reminders by the [Schedule](../../schedule/schedule/README.md) plugin; this controller reports nothing itself.
|
||||
|
||||
<a id="client-references"></a>
|
||||
## Client references
|
||||
|
||||
|
||||
@@ -60,6 +60,8 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在
|
||||
|
||||
附件授权读取内置 Session 事件声明的内容字段与已完成的 assistant 流块,包括扁平的 V4 tool 角色消息。未知事件载荷与无关字段不能授权附件读取。
|
||||
|
||||
本控制器通过 `ctx.plugin` 组合 `ArchivedSessionGate`:在 Agent 注册表、Session store 与 Workspace 注册表就绪后加载,随控制器一起释放。它的 `agent/pre-step` 监听器会拒绝为已归档会话或其子代理子孙——从 Session header 的血缘字段读出,从不包括 fork——提出的步骤,因此迟到的唤醒投递会让该回合以 `blocked` 收口而不发出模型请求;取消归档即为整条血缘解除门禁。已归档会话仍在跑的工作由各自的 owner 通过 Workspace 注册表的归档准入([接缝](../../workspace/workspace/README.zh.md))报告与停止:运行中的回合由 [Agent 注册表](../../core/agent/README.zh.md)负责,所属任务由[任务注册表接缝](../../jobs/jobs/README.zh.md)负责,子代理子孙由 [Subagent](../../subagent/subagent/README.zh.md) runtime 负责,提醒由 [Schedule](../../schedule/schedule/README.zh.md) 插件负责;本控制器自己不报告任何内容。
|
||||
|
||||
<a id="client-references"></a>
|
||||
## Client 引用
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* The controller's admission gate for archived Sessions: an archived
|
||||
* Session, or a subagent descendant of one, must not run a model step until
|
||||
* it is restored. The work a Session still runs is reported and stopped by
|
||||
* its owners — the Agent registry (`turn`), the job registry seam (`job`),
|
||||
* the Subagent runtime (`subagent`), and the Schedule plugin (`schedule`) —
|
||||
* through the Workspace registry's archive-admission events.
|
||||
*/
|
||||
|
||||
import type { Context, Plugin } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-workspace'
|
||||
|
||||
/**
|
||||
* The gate as a plugin for `ctx.plugin(...)`: it loads once the Agent
|
||||
* registry, Session store, and Workspace registry are available and unwinds
|
||||
* with the plugin's fiber. A late waking delivery to an archived Session — a
|
||||
* subagent settlement, a queued follow-up — proposes a step the gate rejects,
|
||||
* which the loop ends as `blocked` without a request; unarchiving lifts the
|
||||
* gate for the whole lineage.
|
||||
*/
|
||||
export const ArchivedSessionGate: Plugin.Object<void> = {
|
||||
name: 'archived-session-gate',
|
||||
inject: ['agents', 'sessions', 'workspaceRegistry'],
|
||||
apply(ctx: Context): void {
|
||||
ctx.on('agent/pre-step', (payload, next) =>
|
||||
underArchivedSession(ctx, payload.agent)
|
||||
? Promise.resolve({ kind: 'reject' as const })
|
||||
: next())
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the Agent's Session, or a Session above it in its subagent lineage,
|
||||
* is archived. Lineage follows the durable header fields through
|
||||
* subagent-origin Sessions only: a fork of an archived Session is an
|
||||
* independent conversation.
|
||||
* @param ctx - Host context.
|
||||
* @param agent - the Agent proposing a step.
|
||||
* @returns whether an archived Session owns the step.
|
||||
*/
|
||||
export function underArchivedSession(ctx: Context, agent: Agent): boolean {
|
||||
const archived = ctx.workspaceRegistry.archivedSessionIds
|
||||
let header = agent.session.header
|
||||
const visited = new Set<SessionId>()
|
||||
while (!visited.has(header.id)) {
|
||||
if (archived.includes(header.id)) return true
|
||||
visited.add(header.id)
|
||||
if (header.origin !== 'subagent' || header.parentSession === undefined) return false
|
||||
const parent = ctx.sessions.get(header.parentSession)
|
||||
if (parent === undefined) return archived.includes(header.parentSession)
|
||||
header = parent.header
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { buildModelCatalog } from './catalog.ts'
|
||||
import { installModelSelectionProjection } from './model-selection-projection.ts'
|
||||
import { SessionSkillCatalog } from './skill-catalog.ts'
|
||||
import { SessionMediaReferences } from './media-references.ts'
|
||||
import { ArchivedSessionGate } from './archived-session-gate.ts'
|
||||
import type {
|
||||
ModelCatalog,
|
||||
SessionWorkspacePathApplication,
|
||||
@@ -158,6 +159,10 @@ export class SessionController extends TypertRemoteService {
|
||||
ctx.plugin(SessionFileReferences)
|
||||
ctx.plugin(SessionMediaReferences)
|
||||
ctx.plugin(SessionSkillCatalog)
|
||||
// An archived Session, or a subagent descendant of one, runs no model step
|
||||
// until it is restored; what it still runs is stopped by the owners that
|
||||
// answer the Workspace registry's archive-admission events.
|
||||
ctx.plugin(ArchivedSessionGate)
|
||||
|
||||
ctx.on('session/created', (session) => {
|
||||
ctx.emit('api-session/added', this.listState.summaryFor(session))
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-workspace'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ArchivedSessionGate } from '../src/archived-session-gate.ts'
|
||||
|
||||
interface Harness {
|
||||
ctx: Context
|
||||
archived: SessionId[]
|
||||
}
|
||||
|
||||
async function harness(): Promise<Harness> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const archived: SessionId[] = []
|
||||
ctx.provide('workspaceRegistry', { get archivedSessionIds() { return archived } } as never)
|
||||
await ctx.plugin(ArchivedSessionGate)
|
||||
return { ctx, archived }
|
||||
}
|
||||
|
||||
/** A registered idle Agent double: the gate reads only its Session header. */
|
||||
async function liveAgent(ctx: Context, session: Session): Promise<Agent> {
|
||||
const agent: Agent = {
|
||||
id: session.id,
|
||||
options: {},
|
||||
session,
|
||||
inbox: unsupportedInbox(),
|
||||
status: 'idle',
|
||||
ctx,
|
||||
send: () => {},
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject: () => {},
|
||||
cancel: () => {},
|
||||
runMaintenance: task => task(new AbortController().signal),
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
await ctx.agents.register(agent)
|
||||
return agent
|
||||
}
|
||||
|
||||
/** Propose one model step for the Agent; the innermost decision enters. */
|
||||
const proposed = (ctx: Context, agent: Agent) => ctx.waterfall(
|
||||
'agent/pre-step',
|
||||
{ agent, messages: [], turn: 1, step: 1, signal: new AbortController().signal },
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
|
||||
)
|
||||
|
||||
describe('Archived-session gate', () => {
|
||||
it('rejects a proposed step for an archived session and lets others through', async () => {
|
||||
const { ctx, archived } = await harness()
|
||||
const hidden = await liveAgent(ctx, ctx.sessions.create(SessionId('hidden')))
|
||||
const visible = await liveAgent(ctx, ctx.sessions.create(SessionId('visible')))
|
||||
archived.push(hidden.id)
|
||||
expect(await proposed(ctx, hidden)).toEqual({ kind: 'reject' })
|
||||
expect(await proposed(ctx, visible)).toEqual({ kind: 'enter', messages: [] })
|
||||
archived.length = 0
|
||||
expect(await proposed(ctx, hidden)).toEqual({ kind: 'enter', messages: [] })
|
||||
})
|
||||
|
||||
it('rejects steps of subagent descendants of an archived session, at any depth, but not of its forks', async () => {
|
||||
const { ctx, archived } = await harness()
|
||||
const root = await liveAgent(ctx, ctx.sessions.create(SessionId('archived-root')))
|
||||
const child = await liveAgent(ctx, ctx.sessions.create(SessionId('woken-child'), {
|
||||
meta: { parentSession: root.id, origin: 'subagent' },
|
||||
}))
|
||||
// A settlement from its own child wakes this continuable child after the
|
||||
// root was archived; the gate must see the archived root above it.
|
||||
const grandchild = await liveAgent(ctx, ctx.sessions.create(SessionId('woken-grandchild'), {
|
||||
meta: { parentSession: child.id, origin: 'subagent' },
|
||||
}))
|
||||
const fork = await liveAgent(ctx, ctx.sessions.create(SessionId('fork-of-archived'), {
|
||||
meta: { parentSession: root.id },
|
||||
}))
|
||||
// A child whose parent Session is not resident is judged by the parent id alone.
|
||||
const orphan = await liveAgent(ctx, ctx.sessions.create(SessionId('orphan-child'), {
|
||||
meta: { parentSession: SessionId('gone-parent'), origin: 'subagent' },
|
||||
}))
|
||||
archived.push(root.id, SessionId('gone-parent'))
|
||||
|
||||
expect(await proposed(ctx, child)).toEqual({ kind: 'reject' })
|
||||
expect(await proposed(ctx, grandchild)).toEqual({ kind: 'reject' })
|
||||
expect(await proposed(ctx, orphan)).toEqual({ kind: 'reject' })
|
||||
expect(await proposed(ctx, fork)).toEqual({ kind: 'enter', messages: [] })
|
||||
archived.length = 0
|
||||
expect(await proposed(ctx, grandchild)).toEqual({ kind: 'enter', messages: [] })
|
||||
})
|
||||
|
||||
it('terminates on a damaged lineage that loops', async () => {
|
||||
const { ctx, archived } = await harness()
|
||||
const first = ctx.sessions.create(SessionId('gate-loop-a'), { meta: { parentSession: SessionId('gate-loop-b'), origin: 'subagent' } })
|
||||
const second = ctx.sessions.create(SessionId('gate-loop-b'), { meta: { parentSession: first.id, origin: 'subagent' } })
|
||||
const agent = await liveAgent(ctx, first)
|
||||
await liveAgent(ctx, second)
|
||||
expect(await proposed(ctx, agent)).toEqual({ kind: 'enter', messages: [] })
|
||||
archived.push(second.id)
|
||||
expect(await proposed(ctx, agent)).toEqual({ kind: 'reject' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Archived-session gate: lifetime', () => {
|
||||
it('stops rejecting once the plugin fiber is disposed', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const archived: SessionId[] = []
|
||||
ctx.provide('workspaceRegistry', { get archivedSessionIds() { return archived } } as never)
|
||||
const agent = await liveAgent(ctx, ctx.sessions.create(SessionId('watched')))
|
||||
archived.push(agent.id)
|
||||
const fiber = await ctx.plugin(ArchivedSessionGate)
|
||||
expect(await proposed(ctx, agent)).toEqual({ kind: 'reject' })
|
||||
await fiber.dispose()
|
||||
expect(await proposed(ctx, agent)).toEqual({ kind: 'enter', messages: [] })
|
||||
})
|
||||
})
|
||||
@@ -33,7 +33,7 @@ async function composed(workspaces: readonly Workspace[] = []): Promise<Context>
|
||||
await ctx.plugin(SystemPrompt, { personaPrefix: '' })
|
||||
await ctx.plugin(AgentRegistry)
|
||||
installSessionReadTestServices(ctx)
|
||||
ctx.provide('workspaceRegistry', { list: () => workspaces } as never)
|
||||
ctx.provide('workspaceRegistry', { list: () => workspaces, archivedSessionIds: [] } as never)
|
||||
ctx.agents.setFactory({
|
||||
createAgent: async (ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> => {
|
||||
const session = ctx.sessions.create(options.sessionId, {
|
||||
@@ -106,7 +106,7 @@ describe('sessions.fork', () => {
|
||||
textResponse('reply'), textResponse('checkpoint'), textResponse('continued'),
|
||||
])
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
ctx.provide('workspaceRegistry', { list: () => [] } as never)
|
||||
ctx.provide('workspaceRegistry', { list: () => [], archivedSessionIds: [] } as never)
|
||||
const source = await harness.create(sid('manual-compaction-source'), { provider: 'mock', model: 'mock' })
|
||||
const originalPrompt = 'older conversation history '.repeat(60)
|
||||
source.followup(message(originalPrompt))
|
||||
@@ -145,7 +145,7 @@ describe('sessions.fork', () => {
|
||||
const harness = await mountAgentLoopTestHarness(ctx)
|
||||
const adapter = new MockAdapter(Array.from({ length: 4 }, () => textResponse('reply')))
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
ctx.provide('workspaceRegistry', { list: () => [] } as never)
|
||||
ctx.provide('workspaceRegistry', { list: () => [], archivedSessionIds: [] } as never)
|
||||
const source = await harness.create(sid('source'), { provider: 'mock', model: 'mock' })
|
||||
source.followup(message('A'))
|
||||
await source.whenIdle()
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"src/list.ts",
|
||||
"src/media-references.ts",
|
||||
"src/model-selection-projection.ts",
|
||||
"src/archived-session-gate.ts",
|
||||
"src/skill-catalog.ts"
|
||||
],
|
||||
"references": [
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/api/workspace-controller/README.md
|
||||
README.md: 27053331007681eb373b4537514bbe701dad660d
|
||||
README.zh.md: d0726496de5dcf623fedb531ad988b3c608fc2ac
|
||||
README.md: b1ca97465625817c28615cbff1badb564ccc29fe
|
||||
README.zh.md: 7f7dbd49c0ef0ebbcb49dfeb6d736dc03e96565c
|
||||
@@ -22,9 +22,9 @@ English | [中文](README.zh.md)
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
The Host controller serializes mutations whose correctness depends on current registry state and throws `RemoteError` with a stable error code for expected failures. Its `follow()` stream synchronously attaches to durable Workspace changes, emits one complete baseline first, then emits ordered `upsert`, `remove`, `order`, `archived`, and `pinned` increments. Archive and pin sets are Session id arrays, with the most recently pinned id first in the pin array. A reconnect starts another generation with a replacement baseline, so consumers do not depend on receiving every increment while disconnected.
|
||||
The Host controller serializes mutations whose correctness depends on current registry state and throws `RemoteError` with a stable error code for expected failures. Its `follow()` stream synchronously attaches to durable Workspace changes, emits one complete baseline first, then emits ordered `upsert`, `remove`, `order`, `archived`, and `pinned` increments. Archive and pin sets are Session id arrays, with the most recently pinned id first in the pin array. A reconnect starts another generation with a replacement baseline, so consumers do not depend on receiving every increment while disconnected. `archiveSession` without `stopActivity` refuses a Session with running work as `workspace/session-active`, whose details list that work by family (`turn`, `subagent`, `job`, `schedule`) with item ids and labels; with `stopActivity: true` the registry's providers stop the work first and the response arrives once the archive set is durable, while the stops settle in the background.
|
||||
|
||||
The Client entry provides `ClientWorkspaceModel` and `createWorkspaceStateStream()`. The model owns Workspace rows, registry order, archived and pinned Session identities, unary mutation echoes, and stream/unary race resolution. A newer Host row wins by `updatedAt`; a committed stream order outranks an older unary response; a removed Workspace id cannot be resurrected by delayed data. Pin snapshots change only when their identities or order change. The package exposes framework-neutral snapshots and subscriptions, leaving navigation policy and React hooks to the UI owner.
|
||||
The Client entry provides `ClientWorkspaceModel` and `createWorkspaceStateStream()`. The model owns Workspace rows, registry order, archived and pinned Session identities, unary mutation echoes, and stream/unary race resolution. A newer Host row wins by `updatedAt`; a committed stream order outranks an older unary response; a removed Workspace id cannot be resurrected by delayed data. Pin snapshots change only when their identities or order change. The package exposes framework-neutral snapshots and subscriptions, leaving navigation policy and React hooks to the UI owner. `WorkspaceController.archiveSession(sessionId, { stopActivity })` throws `WorkspaceArchiveError` with the Host's `rpcError`, so a surface can tell the running-work refusal from a missing Session or a carrier fault and offer to stop the work.
|
||||
|
||||
<a id="first-use-workspace"></a>
|
||||
### First-use Workspace
|
||||
|
||||
@@ -22,9 +22,9 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
Host 控制器会串行执行正确性取决于当前注册表状态的变更,并为预期失败抛出带有稳定错误码的 `RemoteError`。它的 `follow()` 流会同步订阅持久 Workspace 变更,先发出一份完整 baseline,再按顺序发出 `upsert`、`remove`、`order`、`archived` 和 `pinned` 增量。归档与置顶集合都是会话 id 数组,置顶数组把最近置顶的 id 放在前面。重连会以替换 baseline 开始新一代,因此消费方不依赖收到断线期间的每个增量。
|
||||
Host 控制器会串行执行正确性取决于当前注册表状态的变更,并为预期失败抛出带有稳定错误码的 `RemoteError`。它的 `follow()` 流会同步订阅持久 Workspace 变更,先发出一份完整 baseline,再按顺序发出 `upsert`、`remove`、`order`、`archived` 和 `pinned` 增量。归档与置顶集合都是会话 id 数组,置顶数组把最近置顶的 id 放在前面。重连会以替换 baseline 开始新一代,因此消费方不依赖收到断线期间的每个增量。不带 `stopActivity` 的 `archiveSession` 会以 `workspace/session-active` 拒绝仍有工作在跑的会话,其 details 按族(`turn`、`subagent`、`job`、`schedule`)列出这些工作及各项的 id 与名称;带 `stopActivity: true` 时注册表的提供方先停止这些工作,归档集合持久化后即返回响应,停止在后台收敛。
|
||||
|
||||
Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。该模型拥有 Workspace 行、registry 顺序、归档与置顶会话身份、一元变更回显,以及流与一元调用的竞态处理。较新的 Host 行按 `updatedAt` 获胜;已提交的流顺序优先于较旧的一元响应;已经移除的 Workspace id 不会被延迟数据复活。置顶快照仅在会话身份或顺序变化时更新。该包公开与框架无关的快照和订阅,把导航策略与 React 钩子留给 UI owner。
|
||||
Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。该模型拥有 Workspace 行、registry 顺序、归档与置顶会话身份、一元变更回显,以及流与一元调用的竞态处理。较新的 Host 行按 `updatedAt` 获胜;已提交的流顺序优先于较旧的一元响应;已经移除的 Workspace id 不会被延迟数据复活。置顶快照仅在会话身份或顺序变化时更新。该包公开与框架无关的快照和订阅,把导航策略与 React 钩子留给 UI owner。`WorkspaceController.archiveSession(sessionId, { stopActivity })` 抛出携带 Host `rpcError` 的 `WorkspaceArchiveError`,界面因此能区分"仍有工作在跑"的拒绝与会话缺失或载体故障,并提议停止这些工作。
|
||||
|
||||
<a id="first-use-workspace"></a>
|
||||
### 首次使用工作区
|
||||
|
||||
@@ -15,9 +15,12 @@ export { ClientWorkspaceModel } from './model.ts'
|
||||
export type {
|
||||
WorkspaceFollowSink, WorkspaceListPhase, WorkspaceRemote, WorkspaceSnapshot,
|
||||
} from './model.ts'
|
||||
export { WorkspaceController, WorkspaceCreateError } from './service.ts'
|
||||
export { WorkspaceArchiveError, WorkspaceController, WorkspaceCreateError } from './service.ts'
|
||||
export type { IWorkspaces, WorkspaceSource } from './service.ts'
|
||||
export type { WorkspaceId, WorkspaceInitializeDefaultRequest, WorkspaceView } from '../types.ts'
|
||||
export type {
|
||||
SessionActivity, SessionActivityItem, SessionActivityKind, SessionActivityKindMap, WorkspaceId,
|
||||
WorkspaceInitializeDefaultRequest, WorkspaceView,
|
||||
} from '../types.ts'
|
||||
|
||||
type WorkspaceBaselineFrame = Extract<WorkspaceFollowFrame, { type: 'baseline' }>
|
||||
|
||||
|
||||
@@ -189,13 +189,18 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
|
||||
* Archive one Session and install the returned complete archive set.
|
||||
* A reply superseded by a later archive request or a pushed set installs nothing.
|
||||
* @param sessionId - Session to archive.
|
||||
* @param options - Whether the Host stops the Session's running work instead of refusing.
|
||||
* @returns generated Remote result.
|
||||
*/
|
||||
async archiveSession(
|
||||
sessionId: WorkspaceArchiveSessionRequest['sessionId'],
|
||||
options: Pick<WorkspaceArchiveSessionRequest, 'stopActivity'> = {},
|
||||
): Promise<RemoteResult<WorkspaceArchiveValue>> {
|
||||
const requestSeq = ++this.archiveRequestSeq
|
||||
const result = await this.remote.archiveSession({ sessionId })
|
||||
const result = await this.remote.archiveSession({
|
||||
sessionId,
|
||||
...(options.stopActivity === true ? { stopActivity: true } : {}),
|
||||
})
|
||||
if (result.ok && requestSeq === this.archiveRequestSeq) {
|
||||
this.installArchived(result.value.archivedSessionIds)
|
||||
// The Host drops an archived session's pin in the same durable write;
|
||||
|
||||
@@ -17,6 +17,20 @@ export class WorkspaceCreateError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive failed on the Host. `rpcError.code` distinguishes the active-session
|
||||
* refusal (`workspace/session-active`, whose details name what still runs)
|
||||
* from a missing session or a carrier fault.
|
||||
*/
|
||||
export class WorkspaceArchiveError extends Error {
|
||||
override readonly name = 'WorkspaceArchiveError'
|
||||
|
||||
/** @param rpcError - Host business or folded carrier failure. */
|
||||
constructor(readonly rpcError: RemoteFailure) {
|
||||
super(`workspace session archive failed: ${rpcError.code}: ${rpcError.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Bare observable source for the Workspace Controller snapshot. */
|
||||
export interface WorkspaceSource {
|
||||
/** Read the identity-stable current snapshot. */
|
||||
@@ -67,8 +81,11 @@ export interface IWorkspaces {
|
||||
/**
|
||||
* Archive a Session from Workspace grouping surfaces.
|
||||
* @param sessionId - Session to archive.
|
||||
* @param options - `stopActivity` asks the Host to stop the Session's running work instead of refusing.
|
||||
* @throws {WorkspaceArchiveError} when the Host refuses; without `stopActivity` a Session with
|
||||
* running work fails as `workspace/session-active`, its details naming what runs.
|
||||
*/
|
||||
archiveSession(sessionId: SessionId): Promise<void>
|
||||
archiveSession(sessionId: SessionId, options?: { readonly stopActivity?: boolean }): Promise<void>
|
||||
/**
|
||||
* Unarchive a Session from the archived Session list.
|
||||
* @param sessionId - Session to unarchive.
|
||||
@@ -139,9 +156,9 @@ export class WorkspaceController extends Service implements IWorkspaces {
|
||||
if (!result.ok) throw commandError('reorder', result.error)
|
||||
}
|
||||
|
||||
async archiveSession(sessionId: SessionId): Promise<void> {
|
||||
const result = await this.model.archiveSession(sessionId)
|
||||
if (!result.ok) throw commandError('session archive', result.error)
|
||||
async archiveSession(sessionId: SessionId, options: { readonly stopActivity?: boolean } = {}): Promise<void> {
|
||||
const result = await this.model.archiveSession(sessionId, options)
|
||||
if (!result.ok) throw new WorkspaceArchiveError(result.error)
|
||||
}
|
||||
|
||||
async unarchiveSession(sessionId: SessionId): Promise<void> {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Workspace } from '@deepseek-ai/dsh-workspace'
|
||||
import {
|
||||
WorkspaceActiveSessionError,
|
||||
WorkspaceArchivedSessionPinError,
|
||||
WorkspaceId,
|
||||
WorkspaceMoveInvalidError,
|
||||
@@ -151,16 +152,32 @@ export class WorkspaceCommands {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add one known Session to the registry-global archive set.
|
||||
* @param request - Session identity to archive.
|
||||
* Add one known Session to the registry-global archive set. Without
|
||||
* `stopActivity` a Session with running work is refused as
|
||||
* `workspace/session-active` with the activity the registry's providers
|
||||
* reported; with it, the providers stop that work first.
|
||||
* @param request - Session identity to archive and whether to stop its work.
|
||||
* @returns the complete resulting archive set.
|
||||
*/
|
||||
async archiveSession(request: WorkspaceArchiveSessionRequest): Promise<WorkspaceArchiveValue> {
|
||||
try {
|
||||
await this.ctx.workspaceRegistry.archiveSession(request.sessionId)
|
||||
await this.ctx.workspaceRegistry.archiveSession(
|
||||
request.sessionId,
|
||||
request.stopActivity === true ? { stopActivity: true } : {},
|
||||
)
|
||||
} catch (error) {
|
||||
if (!(error instanceof WorkspaceUnknownSessionError)) throw error
|
||||
throw new RemoteError('session/not-found', error.message, { sessionId: request.sessionId }, { cause: error })
|
||||
if (error instanceof WorkspaceUnknownSessionError) {
|
||||
throw new RemoteError('session/not-found', error.message, { sessionId: request.sessionId }, { cause: error })
|
||||
}
|
||||
if (error instanceof WorkspaceActiveSessionError) {
|
||||
throw new RemoteError(
|
||||
'workspace/session-active',
|
||||
error.message,
|
||||
{ sessionId: request.sessionId, activity: error.activity },
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return { archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds] }
|
||||
}
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
*/
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
|
||||
import type { SessionActivity, WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
|
||||
|
||||
export type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
|
||||
export type {
|
||||
SessionActivity, SessionActivityItem, SessionActivityKind, SessionActivityKindMap,
|
||||
} from '@deepseek-ai/dsh-workspace/types'
|
||||
export type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types'
|
||||
|
||||
/** One durable Workspace projected for browser consumers. */
|
||||
@@ -32,6 +35,15 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
'workspace/invalid-path': { readonly path: string }
|
||||
/** Another Workspace already uses the requested name. */
|
||||
'workspace/name-conflict': { readonly name: string }
|
||||
/**
|
||||
* The Session still has running work — its own turn, a subagent, a
|
||||
* background job, or an active schedule — so archiving was refused
|
||||
* without a write; `activity` names what must stop first.
|
||||
*/
|
||||
'workspace/session-active': {
|
||||
readonly sessionId: SessionId
|
||||
readonly activity: readonly SessionActivity[]
|
||||
}
|
||||
/** The Session or its anchor is not in the Workspace's manual order. */
|
||||
'workspace/move-invalid': {
|
||||
readonly workspaceId: WorkspaceId
|
||||
@@ -110,6 +122,14 @@ export interface WorkspaceInsertSessionBeforeRequest {
|
||||
/** Session requested for archival from Workspace grouping surfaces. */
|
||||
export interface WorkspaceArchiveSessionRequest {
|
||||
readonly sessionId: SessionId
|
||||
/**
|
||||
* Stop the Session's running work — its turn, subagent descendants, owned
|
||||
* background jobs, and active schedules — instead of refusing the archive
|
||||
* as `workspace/session-active`. The stops are requested before the
|
||||
* archive write and are not awaited; the response arrives once the archive
|
||||
* set is durable.
|
||||
*/
|
||||
readonly stopActivity?: boolean
|
||||
}
|
||||
|
||||
/** Session requested for restoration from the archived Session list. */
|
||||
|
||||
@@ -13,6 +13,7 @@ import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-clie
|
||||
import {
|
||||
ClientWorkspaceModel,
|
||||
createWorkspaceStateStream,
|
||||
WorkspaceArchiveError,
|
||||
WorkspaceController,
|
||||
WorkspaceCreateError,
|
||||
type WorkspaceFollowSink,
|
||||
@@ -348,7 +349,8 @@ describe('WorkspaceController', () => {
|
||||
expect(mock.log.requests('workspace/rename')).toEqual([{ workspaceId: 'one', title: 'renamed' }])
|
||||
expect(mock.log.requests('workspace/insertBefore')).toEqual([{ workspaceId: 'one' }])
|
||||
expect(mock.log.requests('workspace/insertSessionBefore')).toEqual([{ workspaceId: 'one', sessionId: 'session' }])
|
||||
expect(mock.log.requests('workspace/archiveSession')).toEqual([{ sessionId: 'session' }])
|
||||
await expect(controller.archiveSession(sid('session'), { stopActivity: true })).resolves.toBeUndefined()
|
||||
expect(mock.log.requests('workspace/archiveSession')).toEqual([{ sessionId: 'session' }, { sessionId: 'session', stopActivity: true }])
|
||||
expect(mock.log.requests('workspace/unarchiveSession')).toEqual([{ sessionId: 'session' }])
|
||||
expect(mock.log.requests('workspace/pinSession')).toEqual([{ sessionId: 'session' }])
|
||||
expect(mock.log.requests('workspace/unpinSession')).toEqual([{ sessionId: 'session' }])
|
||||
@@ -376,8 +378,19 @@ describe('WorkspaceController', () => {
|
||||
mock.remote.workspace.insertBefore.mockResolvedValueOnce(err(missingWorkspace))
|
||||
await expect(controller.insertBefore(wid('missing'))).rejects.toThrow('workspace reorder failed: workspace/not-found: gone')
|
||||
mock.remote.workspace.archiveSession.mockResolvedValueOnce(err(missingSession))
|
||||
await expect(controller.archiveSession(sid('session')))
|
||||
.rejects.toThrow('workspace session archive failed: session/not-found: missing session')
|
||||
const archiveMissing = controller.archiveSession(sid('session'))
|
||||
await expect(archiveMissing).rejects.toBeInstanceOf(WorkspaceArchiveError)
|
||||
await expect(archiveMissing).rejects.toThrow('workspace session archive failed: session/not-found: missing session')
|
||||
// The active-session refusal keeps its structured details so a surface
|
||||
// can name what still runs.
|
||||
const active = new RemoteError('workspace/session-active', 'session is active', {
|
||||
sessionId: sid('session'), activity: [{ kind: 'probe' }, { kind: 'probe-items', items: [{ id: 'item-1' }] }],
|
||||
})
|
||||
mock.remote.workspace.archiveSession.mockResolvedValueOnce(err(active))
|
||||
await expect(controller.archiveSession(sid('session'))).rejects.toMatchObject({
|
||||
name: 'WorkspaceArchiveError',
|
||||
rpcError: { code: 'workspace/session-active', details: { activity: [{ kind: 'probe' }, { kind: 'probe-items', items: [{ id: 'item-1' }] }] } },
|
||||
})
|
||||
mock.remote.workspace.unarchiveSession.mockResolvedValueOnce(err(missingSession))
|
||||
await expect(controller.unarchiveSession(sid('session')))
|
||||
.rejects.toThrow('workspace session unarchive failed: session/not-found: missing session')
|
||||
@@ -407,3 +420,11 @@ describe('WorkspaceController', () => {
|
||||
expect(mock.log.calls('workspace/create').map(call => call.state)).toEqual(['failed'])
|
||||
})
|
||||
})
|
||||
|
||||
// The wire relays whatever families the Host's providers report; this suite merges its own.
|
||||
declare module '@deepseek-ai/dsh-workspace/types' {
|
||||
interface SessionActivityKindMap {
|
||||
probe: true
|
||||
'probe-items': true
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,14 @@ import { WorkspaceFeed } from '../src/feed.ts'
|
||||
import type { WorkspaceFollowFrame } from '../src/types.ts'
|
||||
import { MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
|
||||
|
||||
// The controller relays whatever families the providers report; this suite merges its own.
|
||||
declare module '@deepseek-ai/dsh-workspace/types' {
|
||||
interface SessionActivityKindMap {
|
||||
probe: true
|
||||
'probe-items': true
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
interface RemoteErrorDetailsMap {
|
||||
'fixture/failure': {}
|
||||
@@ -232,6 +240,28 @@ describe('WorkspaceController commands', () => {
|
||||
sessionId: session.id,
|
||||
})).rejects.toMatchObject({ code: 'workspace/not-found' })
|
||||
|
||||
// A session reported active by the registry's activity waterfall is a
|
||||
// stable business failure carrying what still runs, and nothing is written.
|
||||
const activity = [{ kind: 'probe' as const }, { kind: 'probe-items' as const, items: [{ id: 'item-1', label: 'build' }] }]
|
||||
const stopReporting = ctx.on('workspace/session-activity', async ({ sessionId }, next) =>
|
||||
sessionId === session.id ? [...activity, ...(await next())] : next())
|
||||
await expect(controller.archiveSession({ sessionId: session.id })).rejects.toMatchObject({
|
||||
code: 'workspace/session-active',
|
||||
details: { sessionId: session.id, activity },
|
||||
})
|
||||
expect([...ctx.workspaceRegistry.archivedSessionIds]).toEqual([])
|
||||
// Asking to stop the work archives the still-active Session and reaches
|
||||
// the stop providers first.
|
||||
const stops: string[] = []
|
||||
const stopListening = ctx.on('workspace/session-stop', ({ sessionId }) => { stops.push(String(sessionId)) })
|
||||
await expect(controller.archiveSession({ sessionId: session.id, stopActivity: true }))
|
||||
.resolves.toEqual({ archivedSessionIds: [session.id] })
|
||||
expect(stops).toEqual([String(session.id)])
|
||||
stopListening()
|
||||
await expect(controller.unarchiveSession({ sessionId: session.id }))
|
||||
.resolves.toEqual({ archivedSessionIds: [] })
|
||||
stopReporting()
|
||||
|
||||
await expect(controller.archiveSession({ sessionId: session.id }))
|
||||
.resolves.toEqual({ archivedSessionIds: [session.id] })
|
||||
await expect(controller.archiveSession({ sessionId: SessionId('unknown') }))
|
||||
|
||||
@@ -105,7 +105,7 @@ it('loads the shipped Office rows with separately patched settings and authorize
|
||||
const before = session.seq
|
||||
const result = await ctx.officeToPdf.render(scope, 'report.docx', 'foreground', signal)
|
||||
expect(result).toEqual({ absolutePath: sourcePath, version, offset: 0, eof: true, bytes: pdf.length,
|
||||
data: pdf.toString('base64'), missingFonts: ['Missing Serif'], generation: ctx.officeToPdf.generation })
|
||||
data: Uint8Array.from(pdf), missingFonts: ['Missing Serif'], generation: ctx.officeToPdf.generation })
|
||||
const readAgain = vi.spyOn(ctx.fs, 'readBytes')
|
||||
expect(await ctx.officeToPdf.render(scope, 'report.docx', 'foreground', signal)).toEqual(result)
|
||||
expect(readAgain).not.toHaveBeenCalled()
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-sidebar-documentpreview/README.md
|
||||
README.md: b82a1ca3179c6a7bb6bf9a67369a4e877d229ed3
|
||||
README.zh.md: 4313b815c01c1d2d0d9b9669a2fb6274b0ca61f8
|
||||
README.md: 510e43fb723039b918672a58ec2bd26cf3b6023a
|
||||
README.zh.md: 5bd9fba7c7f60f7f477afb82b0e88a5dd8b5ac30
|
||||
@@ -57,9 +57,9 @@ The body reads its record, navigation and lifetime through `useTabInfo().tab`. `
|
||||
|
||||
When Developer tools is enabled, HTML fills the body edge to edge in a Blob iframe with exactly `sandbox="allow-scripts"`, without `allow-same-origin`; scripts cannot access the parent application's origin or file reader. The renderer loads directly declared relative `.js` classic scripts and `.css` stylesheets through its injected Remote callback, with fixed safety limits of 4 MiB per asset, 32 MiB total, and 64 distinct assets. Host code resolves the related path, and `readBytes` with `baseFile` returns native bytes. The dependency Resource is added after the read returns, using its `absolutePath` or a string `error.details.path` on failure; without a Host path, the Client does not guess one. Inside the renderer, base64 is used only to embed the iframe bootstrap payload in script text. A `<base href>` leaves dependency resolution to the browser, as do HTTPS resources. Local module imports, CSS `url()`/`@import`, and dynamic `fetch` do not use Host file access. Read failures, invalid UTF-8, or exceeded limits fail the preview rather than publishing a partial asset package. Replacing or unmounting the document releases its Blob URL.
|
||||
|
||||
PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `<img>` static-image context, rounded inside a 12px inset. A wider image scales down to the pane's width at its intrinsic aspect ratio, a smaller image centres at its intrinsic CSS-pixel dimensions, and a taller image scrolls vertically. The renderer provides neither zoom nor drag-to-pan. SVG markup never enters the application DOM or an iframe, so its scripts cannot execute or reach the parent page. Replacing or unmounting the image revokes its Blob URL.
|
||||
PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `<img>` static-image context, rounded inside a 12px inset. Images default to fit width without enlarging content smaller than the pane; 100% uses the image's intrinsic CSS-pixel width. The shared zoom controls can produce horizontal and vertical scrolling, but do not provide drag-to-pan. Zoom changes retain the same `<img>` and Blob URL, so animated images continue playing. Raster formats can soften above their intrinsic size, while SVG remains on the browser's vector rendering path. SVG markup never enters the application DOM or an iframe, so its scripts cannot execute or reach the parent page. Replacing or unmounting the image revokes its Blob URL.
|
||||
|
||||
Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels. PDF and converted Office previews use a graphite background in light mode and a matte-black background in dark mode, with subtle page shadows and original document colors.
|
||||
Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels. PDF and converted Office previews use a graphite background in light mode and a matte-black background in dark mode, with subtle page shadows and original document colors. PDF and image previews share a floating control with **Fit width**, 25%, 50%, 100%, 150%, and 200% choices plus 25% incremental zoom across the 25%–400% fixed range. Fit width is the default and tracks pane resizing; 100% means the PDF's 96-DPI page size or the image's intrinsic CSS-pixel size. On fine pointers, the control starts hidden, slides up when the pointer enters the bottom 72 CSS pixels, and slides down after a 420 ms leave delay; hover, keyboard focus, an open menu, and an active gesture keep it visible, while devices without hover keep it visible continuously. Chromium maps a macOS trackpad pinch to the same live, pointer-anchored path as Ctrl+wheel; the control displays the gesture percentage, and the settled value survives body remounts until the tab closes. HTML previews do not expose zoom controls.
|
||||
|
||||
Initial reads, additional pages, and HTML/PDF/image preparation share the ongoing `StateDot` loader, which exposes its label to assistive technology and respects reduced-motion preferences; every wait before content exists centres the loader in the pane, so opening a file shows one indicator in one position until the body appears. Loaded pages stay visible while another page loads. The PDF body loads its package-local `client.pdf.js` chunk only when a PDF preview mounts; PDF.js, its Worker source, and embedded support data stay out of the startup `client.js`. PDF pages fill the pane's width edge to edge as one vertical sequence and render lazily near the viewport; an unrendered page holds its place as a quiet 3:4 placeholder block. PDF.js’s official TextLayerBuilder manages selection boundaries and normalized copying over an aligned text layer. Its companion styles keep blank line breaks unhighlighted; alignment accounts for PDF page units, page rotation, and viewport resizing, and page disposal cancels both layers. Image-only PDFs contain no selectable text. Code previews show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control.
|
||||
|
||||
@@ -85,7 +85,7 @@ The read-only formula bar displays formulas and cell text literally. Copying pre
|
||||
<a id="office-preview"></a>
|
||||
## Office preview
|
||||
|
||||
Open `.doc`, `.docx`, `.ppt`, and `.pptx` as PDF previews with the same loading state, controls, cancellation, and selectable text as PDF files. The [Host provider](../../document/office-to-pdf/README.md) performs local conversion and retains its spreadsheet conversion API for other consumers. Invalid supported files, conversion failures, and timeouts receive localized messages. Missing Host services show configuration guidance.
|
||||
Open `.doc`, `.docx`, `.ppt`, and `.pptx` as PDF previews with the same loading state, zoom controls, cancellation, and selectable text as PDF files. The [Host provider](../../document/office-to-pdf/README.md) performs local conversion and retains its spreadsheet conversion API for other consumers. Invalid supported files, conversion failures, and timeouts receive localized messages. Missing Host services show configuration guidance.
|
||||
|
||||
The [Web bundle](../../bundle/web-app/README.md) mounts this package as `ui-sidebar-documentpreview`. Configure its transient Office cache through that entry's `office` settings; the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-client-ui-sidebar-documentpreview) defines accepted values. Settings are embedded in each served page; reload the browser page after changing YAML.
|
||||
|
||||
@@ -106,7 +106,7 @@ A yellow rounded-triangle warning appears before Reload in the document toolbar
|
||||
|
||||
Office registration, loading, caching, and font notices live in `src/client/office/`. The injected Office face writes converted PDF bytes, font metadata, and failures through the declared store actions. The Office body triggers loading, binds cancellation to its lifecycle, and declares a nested PDF slot that reuses the lazy PDF body and its tab viewing state. The keyed `sidebar.right.tab.document.action` slot places renderer controls before Reload. The Office action shares the body’s store and reads only the current revision’s font metadata. Registration remains available without the Host renderer; optional `remote.officeToPdf` and `remote.workspaceFiles` injections supply conversion and freshness callbacks, and their removal restores unavailable guidance. Registrations and tab retention follow effect lifetimes. The [conversion service](../../document/office-to-pdf/README.md) owns the Host Remote methods, mounted by `api/remotes`.
|
||||
|
||||
The `documentFileBytes()` helper decodes converted Office PDF responses into one owned byte buffer without expanding bytes into JavaScript array elements. Renderers borrow retained bytes read-only and copy them before Worker transfer.
|
||||
The Office Remote returns converted PDFs as native `Uint8Array` values through Connection's multipart binary transport. Renderers borrow retained bytes read-only and copy them before Worker transfer.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -130,7 +130,8 @@ No direct effect; what the user reads here never enters a model request.
|
||||
- **Preview, not editing.** The viewers provide no file editing or shared search interface; a directory address fails with `not-regular-file`. Unknown extensions use the plain-text reader and remain subject to its UTF-8/NUL checks.
|
||||
- **Office conversion limits.** The preview does not launch native Office editors or download an engine. Binary `.doc` and `.ppt` files return no missing-font diagnostics. Conversion fidelity and resource limits belong to the [LibreOffice provider](../../document/office-to-pdf/README.md).
|
||||
- **Sequential text and bounded complete files.** Deep source lines require the preceding pages; PDF, HTML, and images require a complete result within the Host's `maxFileBytes` cap.
|
||||
- **Byte-view scroll state is not restored.** PDF, HTML, and images can return to the top when their renderer remounts or reloads; images fit the pane's width and never scroll horizontally, and HTML iframe scrolling belongs to its opaque browsing context.
|
||||
- **PDF zoom does not increase raster resolution.** PDF pages render at 96 DPI times the device pixel ratio, subject to the existing 16-million-pixel canvas limit, and fixed zoom enlarges that bitmap. High percentages can therefore soften page text; viewport-near rerendering at the settled zoom remains deferred.
|
||||
- **Byte-view scroll state is not restored.** PDF, HTML, and images can return to the top when their renderer remounts or reloads; fixed PDF and image zoom can overflow horizontally, and HTML iframe scrolling belongs to its opaque browsing context.
|
||||
- **Finite local HTML dependencies.** Only direct classic `.js` and stylesheet `.css` references are packed. Browser-resolved resources retain browser origin and network restrictions; no runtime file-read bridge is exposed to the iframe.
|
||||
- **Scroll writes are unthrottled.** Every scroll event records its offset in the store; the line blocks are memoized so the resulting re-render hands React the same elements back.
|
||||
- **A failed PDF chunk load requires a page reload.** React caches a rejected lazy import for the page lifetime; ordinary PDF open or render failures remain retryable inside the loaded body.
|
||||
|
||||
@@ -57,9 +57,9 @@ Web 和桌面端均通过开发者工具选择 HTML 预览策略。渲染器从
|
||||
|
||||
开启开发者工具时,HTML 以贴合正文四边的 Blob iframe 运行,沙箱属性严格为 `sandbox="allow-scripts"`,不含 `allow-same-origin`;脚本无法访问父应用的源或文件读取接口。渲染器通过注入的 Remote 回调,加载直接声明的相对 `.js` 经典脚本和 `.css` 样式表;固定安全上限为单个资源 4 MiB、总计 32 MiB、64 个不同资源。Host 代码解析关联路径,带 `baseFile` 的 `readBytes` 返回原生字节。依赖 Resource 在读取返回后加入,使用返回的 `absolutePath`,失败时则使用字符串类型的 `error.details.path`;没有 Host 路径时,Client 不自行猜测。在渲染器内部,base64 仅用于把 iframe 引导载荷嵌入脚本文本。`<base href>` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。
|
||||
|
||||
PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态图片上下文中渲染,带 12px 内边距和圆角。宽图按固有纵横比缩小到面板宽度,小图按固有 CSS 像素尺寸居中,超高图纵向滚动。渲染器既不提供缩放,也不提供拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。
|
||||
PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态图片上下文中渲染,带 12px 内边距和圆角。图片默认适应宽度,但不会放大小于面板的内容;100% 使用图片的固有 CSS 像素宽度。共享缩放控件可产生横向和纵向滚动,但不提供拖拽平移。缩放不会替换 `<img>` 或 Blob URL,因此动画图片会继续播放。位图超过固有尺寸后可能变虚,SVG 则继续使用浏览器的矢量渲染路径。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。
|
||||
|
||||
共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。PDF 与转换后的 Office 预览在浅色模式下使用石墨灰底色,在深色模式下使用哑黑底色,页面带有轻微阴影并保留文档原色。
|
||||
共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。PDF 与转换后的 Office 预览在浅色模式下使用石墨灰底色,在深色模式下使用哑黑底色,页面带有轻微阴影并保留文档原色。PDF 与图片预览共用浮动控件,提供**适应宽度**、25%、50%、100%、150% 和 200% 选项,并在 25%–400% 固定范围内按 25% 逐级缩放。适应宽度是默认模式并跟随面板尺寸;100% 表示 PDF 的 96 DPI 页面尺寸或图片的固有 CSS 像素尺寸。在精细指针设备上,控件初始隐藏;指针进入底部 72 个 CSS 像素时向上滑入,离开 420 ms 后向下退出;控件悬停、键盘焦点、菜单展开和手势进行期间保持显示,无 hover 的设备则始终显示。Chromium 将 macOS 触控板捏合映射到与 Ctrl+滚轮相同的实时指针锚定路径;控件同步显示手势比例,静止后的值在正文重新挂载后继续保留,直至 tab 关闭。HTML 预览不显示缩放控件。
|
||||
|
||||
首次读取、追加页及 HTML/PDF/图片准备共用 ongoing `StateDot` loading,其标签暴露给辅助技术,并遵循减少动态效果偏好;内容出现前的每个等待都把 loading 居中在面板中,打开文件到正文出现始终是同一位置的一个标记。下一页加载期间保留已显示的内容。PDF 正文仅在 PDF 预览挂载时加载包内 `client.pdf.js` chunk;PDF.js、Worker 源码和内嵌支持数据不会进入启动 `client.js`。PDF 页面贴边占满面板宽度,组成一个纵向连续序列并在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。PDF.js 官方 TextLayerBuilder 在与画面重合的文字层上管理选区边界和复制文本规范化。配套样式不高亮空白换行;对齐同时考虑 PDF 页面单位、页面旋转与视口宽度变化,页面释放时取消两层渲染。纯图片 PDF 不包含可选取的文字。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。
|
||||
|
||||
@@ -85,7 +85,7 @@ CSV 和 TSV 默认使用表格查看器,也可选择纯文本。两者分别
|
||||
<a id="office-preview"></a>
|
||||
## Office 预览
|
||||
|
||||
将 `.doc`、`.docx`、`.ppt` 和 `.pptx` 打开为 PDF 预览,使用与 PDF 文件相同的加载状态、控件、取消和文本选择能力。[Host 提供方](../../document/office-to-pdf/README.zh.md)负责本地转换,并为其他消费者保留电子表格转换 API。受支持的无效文件、转换失败和超时会显示本地化消息。缺少 Host 服务时显示配置引导。
|
||||
将 `.doc`、`.docx`、`.ppt` 和 `.pptx` 打开为 PDF 预览,使用与 PDF 文件相同的加载状态、缩放控件、取消和文本选择能力。[Host 提供方](../../document/office-to-pdf/README.zh.md)负责本地转换,并为其他消费者保留电子表格转换 API。受支持的无效文件、转换失败和超时会显示本地化消息。缺少 Host 服务时显示配置引导。
|
||||
|
||||
[Web bundle](../../bundle/web-app/README.zh.md) 以 `ui-sidebar-documentpreview` 挂载本包。通过该条目的 `office` 设置配置临时 Office 缓存;[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-client-ui-sidebar-documentpreview)定义可接受的值。设置注入到每个页面;修改 YAML 后重新加载浏览器页面。
|
||||
|
||||
@@ -106,7 +106,7 @@ CSV 和 TSV 默认使用表格查看器,也可选择纯文本。两者分别
|
||||
|
||||
Office 注册、加载、缓存和字体提示位于 `src/client/office/`。注入的 Office face 通过已声明的 store action 写入转换后的 PDF 字节、字体元数据和失败。Office 正文触发加载,将取消绑定到自身生命周期,并声明嵌套 PDF slot,复用惰性 PDF 正文及其 tab 阅读状态。keyed slot `sidebar.right.tab.document.action` 将渲染器操作放在刷新按钮前。Office 操作与正文共享 store,仅读取当前 revision 的字体元数据。Host 渲染器缺失时,注册仍然可用;可选的 `remote.officeToPdf` 和 `remote.workspaceFiles` 注入提供转换与版本检查回调,移除后恢复不可用提示。注册和 tab 状态保留都遵循 effect 生命周期。[转换服务](../../document/office-to-pdf/README.zh.md)拥有 Host Remote 方法,由 `api/remotes` 挂载。
|
||||
|
||||
`documentFileBytes()` 辅助函数将 Office 转换后的 PDF 响应解码到一个独立持有的字节缓冲区,不会将字节展开为 JavaScript 数组元素。渲染器以只读方式借用保留的字节,并在传给 Worker 前复制。
|
||||
Office Remote 通过 Connection 的 multipart 二进制传输返回原生 `Uint8Array` PDF。渲染器以只读方式借用保留的字节,并在传给 Worker 前复制。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -130,7 +130,8 @@ Office 注册、加载、缓存和字体提示位于 `src/client/office/`。注
|
||||
- **预览而非编辑。** 查看器不提供文件编辑或共享搜索接口;目录地址以 `not-regular-file` 失败。未知扩展名使用纯文本读取,仍受其 UTF-8/NUL 检查限制。
|
||||
- **Office 转换限制。** 预览不启动原生 Office 编辑器,也不下载引擎。二进制 `.doc` 和 `.ppt` 文件不返回缺失字体诊断。转换保真度与资源限制由 [LibreOffice 提供方](../../document/office-to-pdf/README.zh.md)负责。
|
||||
- **文本顺序分页,完整文件受限。** 定位到较深处的源码行需要先加载此前各页;PDF、HTML 和图片必须取得 Host `maxFileBytes` 上限内的完整结果。
|
||||
- **字节视图不恢复滚动位置。** PDF、HTML 与图片的渲染器重新挂载或重新载入时可能回到顶部;图片适配面板宽度、不产生横向滚动,HTML iframe 的滚动属于其不透明浏览上下文。
|
||||
- **PDF 缩放不会提高栅格分辨率。** PDF 页面按 96 DPI 乘以设备像素比绘制,并受现有 1600 万像素 Canvas 上限约束;固定缩放会放大这张位图,因此高比例下页面文字可能变虚。按稳定缩放比例重绘视口附近页面仍属于延期工作。
|
||||
- **字节视图不恢复滚动位置。** PDF、HTML 与图片的渲染器重新挂载或重新载入时可能回到顶部;固定 PDF 与图片缩放可产生横向滚动,HTML iframe 的滚动属于其不透明浏览上下文。
|
||||
- **本地 HTML 依赖集合有限。** 只打包直接引用的经典 `.js` 脚本和 `.css` 样式表。浏览器解析的资源仍受浏览器源与网络规则限制;iframe 不获得运行时文件读取桥接。
|
||||
- **滚动写入未节流。** 每次滚动事件都把偏移记进 store;行块已 memo 化,于是由此引发的重渲染交还给 React 的是同一批元素。
|
||||
- **PDF chunk 加载失败后需要刷新页面。** React 会在页面生命周期内缓存被拒绝的 lazy import;已加载正文中的普通 PDF 打开或渲染失败仍可重试。
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
height: max-content;
|
||||
min-height: 100%;
|
||||
padding: 12px;
|
||||
@@ -14,20 +15,23 @@
|
||||
|
||||
/* A wider image shrinks to the pane's width at its aspect ratio; a smaller
|
||||
one keeps its intrinsic size. The auto margins centre it either way. */
|
||||
.image {
|
||||
.surface {
|
||||
display: block;
|
||||
flex: none;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
max-width: none;
|
||||
margin: auto;
|
||||
border-radius: 8px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.image[hidden] {
|
||||
display: none;
|
||||
.surface[hidden] { display: none; }
|
||||
|
||||
.image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
border-radius: 8px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.status {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
/** Complete image bytes rendered rounded within an inset frame, scaled down to the pane's width. */
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
/** Complete image bytes rendered in a shared zoom viewport. */
|
||||
import { useCallback, useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
|
||||
import type { PropsLocale, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import type { DocumentPreviewProps } from '../document/contract.ts'
|
||||
import { LoadingIndicator } from '../LoadingIndicator.tsx'
|
||||
import { hostFileOf } from '../rpc.ts'
|
||||
import { ZoomViewport, zoomSurfaceClass } from '../zoom/ZoomViewport.tsx'
|
||||
import { DEFAULT_ZOOM, type ZoomInjected, type ZoomStore } from '../zoom/store.ts'
|
||||
import type { ZoomLabels, ZoomPreference } from '../zoom/types.ts'
|
||||
import type {} from './locales.ts'
|
||||
import css from './ImageBody.module.css'
|
||||
|
||||
@@ -22,7 +25,7 @@ const IMAGE_MEDIA_TYPES = {
|
||||
type ImageMediaType = typeof IMAGE_MEDIA_TYPES[keyof typeof IMAGE_MEDIA_TYPES]
|
||||
|
||||
/** Standard document props plus the image renderer's dictionary. */
|
||||
export type ImageBodyProps = DocumentPreviewProps & PropsLocale<'sidebarImage'>
|
||||
export type ImageBodyProps = DocumentPreviewProps & PropsLocale<'sidebarImage'> & PropsStore<ZoomStore> & ZoomInjected
|
||||
|
||||
type ImageSource =
|
||||
| {
|
||||
@@ -46,17 +49,23 @@ export function imageMediaType(path: string): ImageMediaType | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Present complete image bytes fitted to the pane's width.
|
||||
* Present complete image bytes with fit-width and fixed-scale viewing.
|
||||
* @param props - document bytes, resource identity, and locale.
|
||||
* @returns a rounded image scaled down to the pane's width at its aspect
|
||||
* ratio — a smaller image centres at its intrinsic size — whose containing
|
||||
* document body provides vertical scrolling.
|
||||
* @returns a rounded image fitted or scaled at its intrinsic aspect ratio.
|
||||
*/
|
||||
export function ImageBody({ content, resourceAddress, t }: ImageBodyProps): ReactNode {
|
||||
export function ImageBody(props: ImageBodyProps): ReactNode {
|
||||
const { content, resourceAddress, t } = props
|
||||
const { tab } = props.useTabInfo()
|
||||
const preference = props.useStore(state => state.byTab[tab.id] ?? DEFAULT_ZOOM)
|
||||
const path = useMemo(() => hostFileOf(resourceAddress).path, [resourceAddress])
|
||||
const mediaType = imageMediaType(path)
|
||||
const data = content.kind === 'bytes' ? content.data : undefined
|
||||
const [source, setSource] = useState<ImageSource>()
|
||||
const setPreference = useCallback((value: ZoomPreference): void => {
|
||||
props.actions.zoom(tab.id, value)
|
||||
}, [props.actions, tab.id])
|
||||
|
||||
useEffect(() => { props.retainTab(tab.id, tab.signal) }, [props.retainTab, tab.id, tab.signal])
|
||||
|
||||
useEffect(() => {
|
||||
if (data === undefined || mediaType === undefined) return
|
||||
@@ -80,29 +89,46 @@ export function ImageBody({ content, resourceAddress, t }: ImageBodyProps): Reac
|
||||
}
|
||||
if (source.kind === 'failed') return <p className={css.status} role="alert">{t('failed')}</p>
|
||||
const { name } = pathPartsOf(path)
|
||||
return <LoadedImage key={source.url} url={source.url} name={name} t={t} />
|
||||
const labels: ZoomLabels = {
|
||||
controls: t('zoomControls'), menu: t('zoomMenu'), out: t('zoomOut'), into: t('zoomIn'),
|
||||
fitWidth: t('zoomFitWidth'), value: percent => t('zoomValue', { percent }),
|
||||
}
|
||||
return <LoadedImage key={source.url} url={source.url} name={name} preference={preference}
|
||||
onPreference={setPreference} labels={labels} signal={tab.signal}
|
||||
scrollportRef={props.scrollportRef} t={t} />
|
||||
}
|
||||
|
||||
/** SVG stays in the browser's static image mode because its bytes only reach an img Blob URL. */
|
||||
function LoadedImage({ url, name, t }: {
|
||||
function LoadedImage({ url, name, preference, onPreference, labels, signal, scrollportRef, t }: {
|
||||
readonly url: string
|
||||
readonly name: string
|
||||
readonly preference: ZoomPreference
|
||||
readonly onPreference: (preference: ZoomPreference) => void
|
||||
readonly labels: ZoomLabels
|
||||
readonly signal: AbortSignal
|
||||
readonly scrollportRef: ImageBodyProps['scrollportRef']
|
||||
readonly t: ImageBodyProps['t']
|
||||
}): ReactNode {
|
||||
const [state, setState] = useState<'loading' | 'ready' | 'failed'>('loading')
|
||||
return <div className={css.frame} data-image-preview>
|
||||
{state === 'loading' && <LoadingIndicator className={css.status} label={t('loading')} />}
|
||||
{state === 'failed' && <p className={css.status} role="alert">{t('failed')}</p>}
|
||||
<img
|
||||
className={css.image}
|
||||
src={url}
|
||||
alt={t('preview', { name })}
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
referrerPolicy="no-referrer"
|
||||
hidden={state !== 'ready'}
|
||||
onLoad={() => { setState('ready') }}
|
||||
onError={() => { setState('failed') }}
|
||||
/>
|
||||
</div>
|
||||
const [width, setWidth] = useState<number>()
|
||||
return <ZoomViewport preference={preference} intrinsicWidth={width} horizontalInset={24}
|
||||
labels={labels} signal={signal} scrollportRef={scrollportRef} onPreference={onPreference}>
|
||||
<div className={css.frame} data-image-preview>
|
||||
{state === 'loading' && <LoadingIndicator className={css.status} label={t('loading')} />}
|
||||
{state === 'failed' && <p className={css.status} role="alert">{t('failed')}</p>}
|
||||
<div className={`${css.surface} ${zoomSurfaceClass}`} data-document-zoom-surface
|
||||
style={{ '--document-zoom-width': `${width ?? 0}px` } as CSSProperties} hidden={state !== 'ready'}>
|
||||
<img
|
||||
className={css.image}
|
||||
src={url}
|
||||
alt={t('preview', { name })}
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
referrerPolicy="no-referrer"
|
||||
onLoad={(event) => { setWidth(event.currentTarget.naturalWidth); setState('ready') }}
|
||||
onError={() => { setState('failed') }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ZoomViewport>
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
/** Builtin image metadata and keyed document-body registration. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { BoundActions } from '@deepseek-ai/dsh-client-store'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type {} from '../index.ts'
|
||||
import type { DocumentPreviewDefinition } from '../document/registry.ts'
|
||||
import { retainDocumentTabs } from '../document/tab-lifetime.ts'
|
||||
import { createZoomStore, type ZoomInjected, type ZoomStore } from '../zoom/store.ts'
|
||||
import { ImageBody } from './ImageBody.tsx'
|
||||
import { en, zh } from './locales.ts'
|
||||
|
||||
@@ -39,7 +43,12 @@ export function apply(ctx: Context): void {
|
||||
const t = ctx.locale.bind('sidebarImage')
|
||||
ctx.effect(() => ctx.locale.register('sidebarImage', { zh, en }), 'document-image: dictionaries')
|
||||
ctx.effect(() => ctx.documentPreviews.register(imageBodyDefinition(() => t('title'))), 'document-image: metadata')
|
||||
ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document', () => ctx.slots.register(
|
||||
{ name: 'sidebar.right.tab.document', key: IMAGE_BODY_ID, locale: 'sidebarImage' }, ImageBody,
|
||||
)), 'document-image: body')
|
||||
const store = createZoomStore()
|
||||
const retainTab = retainDocumentTabs(ctx)
|
||||
ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document', () => ctx.slots.register({
|
||||
name: 'sidebar.right.tab.document', key: IMAGE_BODY_ID, locale: 'sidebarImage', store,
|
||||
inject: (_sessionId: SessionId, actions: BoundActions<ZoomStore>): ZoomInjected => ({
|
||||
retainTab: (tabId, signal) => { retainTab(tabId, signal, actions.forget) },
|
||||
}),
|
||||
}, ImageBody)), 'document-image: body')
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { zoomEn, zoomZh } from '../zoom/locales.ts'
|
||||
|
||||
/** Locale-owned image renderer labels and status text. */
|
||||
export const zh = {
|
||||
...zoomZh,
|
||||
title: '图片',
|
||||
preview: '图片预览:{name}',
|
||||
loading: '正在读取…',
|
||||
@@ -12,6 +15,7 @@ export type ImagePreviewKey = keyof typeof zh
|
||||
|
||||
/** English dictionary with the same keys as the Chinese dictionary. */
|
||||
export const en = {
|
||||
...zoomEn,
|
||||
title: 'Image',
|
||||
preview: 'Image preview: {name}',
|
||||
loading: 'Reading…',
|
||||
|
||||
@@ -4,9 +4,3 @@
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.scrollport {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -70,11 +70,9 @@ export function OfficeBody(props: OfficeBodyProps): ReactNode {
|
||||
}
|
||||
if (file === undefined) return <LoadingIndicator className={common.statusLine} label={t('loading')} />
|
||||
return <div className={css.body}>
|
||||
<div className={css.scrollport} ref={props.scrollportRef}>
|
||||
{props.renderSlot('sidebar.right.tab.document.office.pdf', {
|
||||
resourceAddress, content: { kind: 'bytes', data: file.data }, wrap: props.wrap, scrollportRef: props.scrollportRef,
|
||||
addResource: props.addResource, setResources: props.setResources,
|
||||
}, { entryKey: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office', hookContext: props.useTabInfo })}
|
||||
</div>
|
||||
{props.renderSlot('sidebar.right.tab.document.office.pdf', {
|
||||
resourceAddress, content: { kind: 'bytes', data: file.data }, wrap: props.wrap, scrollportRef: props.scrollportRef,
|
||||
addResource: props.addResource, setResources: props.setResources,
|
||||
}, { entryKey: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office', hookContext: props.useTabInfo })}
|
||||
</div>
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type {} from '@deepseek-ai/dsh-office-to-pdf/remote'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-api-workspace-files/remote'
|
||||
import { documentFileBytes } from '../rpc.ts'
|
||||
import { failureLine } from '../failure-line.ts'
|
||||
import { documentTabInfoFactory } from '../document/contract.ts'
|
||||
import { en, zh, type OfficePreviewKey } from './locales.ts'
|
||||
@@ -73,14 +72,10 @@ export function apply(ctx: Context, config: Config['office']): void {
|
||||
signal.throwIfAborted()
|
||||
const result = await scope.remote.officeToPdf.render(file.sessionId, file.path, priority, signal)
|
||||
signal.throwIfAborted()
|
||||
if (!result.ok) {
|
||||
if (result.error.code === 'document-render/failed') {
|
||||
throw new Error(t(conversionErrorKey(result.error.details.reason)), { cause: result.error })
|
||||
}
|
||||
return result
|
||||
if (!result.ok && result.error.code === 'document-render/failed') {
|
||||
throw new Error(t(conversionErrorKey(result.error.details.reason)), { cause: result.error })
|
||||
}
|
||||
return { ok: true, value: { ...documentFileBytes(result.value),
|
||||
missingFonts: result.value.missingFonts, generation: result.value.generation } }
|
||||
return result
|
||||
}
|
||||
const createCache = () => new OfficePreviewCache(
|
||||
async (file, signal) => {
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
min-width: 100%;
|
||||
overflow: visible;
|
||||
background: var(--dsw-alias-bg-document-preview);
|
||||
font-family: var(--dsw-font, sans-serif);
|
||||
white-space: normal;
|
||||
@@ -14,7 +15,7 @@
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-x: visible;
|
||||
}
|
||||
|
||||
/* An unrendered page holds its place as a quiet block; the block itself is
|
||||
@@ -27,15 +28,15 @@
|
||||
|
||||
.canvas {
|
||||
display: block;
|
||||
width: min(var(--pdf-page-width), 100%);
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
height: auto;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.surface {
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
max-width: none;
|
||||
margin-inline: auto;
|
||||
box-shadow: var(--dsw-elevation-prominent);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { LazyPdfBody } from './LazyPdfBody.tsx'
|
||||
import type { BoundActions } from '@deepseek-ai/dsh-client-store'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { createPdfStore, type PdfStore } from './store.ts'
|
||||
import { ZoomViewport, zoomSurfaceClass } from '../zoom/ZoomViewport.tsx'
|
||||
import { en, zh } from './locales.ts'
|
||||
|
||||
/** PDF metadata and keyed body share this package-local implementation identity. */
|
||||
@@ -48,6 +49,8 @@ export function pdfBodyRegistration(ctx: Context): {
|
||||
store,
|
||||
inject: (_sessionId, actions): PdfBodyInjected => ({
|
||||
retainTab: (tabId, signal) => { retainTab(tabId, signal, actions.forget) },
|
||||
ZoomViewport,
|
||||
zoomSurfaceClass,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { zoomEn, zoomZh } from '../zoom/locales.ts'
|
||||
|
||||
/** Copy owned by the PDF renderer. */
|
||||
export const zh = {
|
||||
...zoomZh,
|
||||
title: 'PDF',
|
||||
pageImage: 'PDF 第 {page} 页',
|
||||
loading: '正在读取…',
|
||||
@@ -16,6 +19,7 @@ export type PdfLocaleKey = keyof typeof zh
|
||||
|
||||
/** English PDF-renderer dictionary. */
|
||||
export const en = {
|
||||
...zoomEn,
|
||||
title: 'PDF',
|
||||
pageImage: 'PDF page {page}',
|
||||
loading: 'Reading…',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** PDF page presentation; binary content and tab information come from the document owner. */
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState, type ComponentType, type CSSProperties, type ReactNode } from 'react'
|
||||
import { Button, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
|
||||
@@ -9,6 +9,8 @@ import { renderPdfPage, type PdfDocument } from './document.ts'
|
||||
import { openPdf } from './runtime.ts'
|
||||
import { PdfWorkerFailure } from './errors.ts'
|
||||
import { pdfTextRenderer } from './text.ts'
|
||||
import type { ZoomViewportProps } from '../zoom/ZoomViewport.tsx'
|
||||
import type { ZoomLabels, ZoomPreference } from '../zoom/types.ts'
|
||||
import type {} from './locales.ts'
|
||||
import css from './PdfBody.module.css'
|
||||
|
||||
@@ -20,6 +22,10 @@ export interface PdfBodyInjected {
|
||||
* @param signal - tab-record lifetime, not body visibility.
|
||||
*/
|
||||
readonly retainTab: (tabId: TabId, signal: AbortSignal) => void
|
||||
/** Main-bundle viewport reused by the lazy PDF body. */
|
||||
readonly ZoomViewport: ComponentType<ZoomViewportProps>
|
||||
/** Main-bundle class implementing fit-width and fixed-size surfaces. */
|
||||
readonly zoomSurfaceClass: string
|
||||
}
|
||||
|
||||
/** Standard document props plus the PDF entry's locale, viewing store, and lifetime callback. */
|
||||
@@ -30,7 +36,7 @@ type LoadState =
|
||||
| { readonly kind: 'failed'; readonly data: Uint8Array<ArrayBuffer>; readonly error: unknown }
|
||||
|
||||
const DEFAULT_PDF_VIEW: PdfView = { page: 1 }
|
||||
|
||||
const FIT_WIDTH: ZoomPreference = { kind: 'fit-width' }
|
||||
/**
|
||||
* Present a PDF with tab-local viewing preferences and component-owned rendering resources.
|
||||
* @param props - complete bytes and framework-owned tab/store/locale seats.
|
||||
@@ -42,10 +48,19 @@ export function PdfBody(props: PdfBodyProps): ReactNode {
|
||||
const data = props.content.kind === 'bytes' ? props.content.data : undefined
|
||||
const [load, setLoad] = useState<LoadState>()
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const { retainTab, actions, t } = props
|
||||
const [pageWidth, setPageWidth] = useState<number>()
|
||||
const { retainTab, actions, t, ZoomViewport, zoomSurfaceClass } = props
|
||||
const preference = view.zoom ?? FIT_WIDTH
|
||||
const labels: ZoomLabels = {
|
||||
controls: t('zoomControls'), menu: t('zoomMenu'), out: t('zoomOut'), into: t('zoomIn'),
|
||||
fitWidth: t('zoomFitWidth'), value: percent => t('zoomValue', { percent }),
|
||||
}
|
||||
const pageVisible = useCallback((page: number): void => {
|
||||
actions.page(tab.id, page)
|
||||
}, [actions, tab.id])
|
||||
const setPreference = useCallback((value: ZoomPreference): void => {
|
||||
actions.zoom(tab.id, value)
|
||||
}, [actions, tab.id])
|
||||
|
||||
useEffect(() => { retainTab(tab.id, tab.signal) }, [retainTab, tab.id, tab.signal])
|
||||
useEffect(() => {
|
||||
@@ -78,20 +93,27 @@ export function PdfBody(props: PdfBodyProps): ReactNode {
|
||||
<Button size="sm" onClick={() => { setAttempt(value => value + 1) }}>{t('retry')}</Button>
|
||||
</div>
|
||||
}
|
||||
return <section className={css.body} data-pdf-preview>
|
||||
const pages = <section className={css.body} data-pdf-preview>
|
||||
{Array.from({ length: load.document.numPages }, (_, index) => (
|
||||
<PdfPage key={index} document={load.document} page={index + 1}
|
||||
requested={index === 0 || view.page === index + 1} onVisible={pageVisible} signal={tab.signal} t={t} />
|
||||
requested={index === 0 || view.page === index + 1} onVisible={pageVisible} signal={tab.signal}
|
||||
onWidth={index === 0 ? setPageWidth : undefined} zoomSurfaceClass={zoomSurfaceClass} t={t} />
|
||||
))}
|
||||
</section>
|
||||
return <ZoomViewport preference={preference} intrinsicWidth={pageWidth} labels={labels}
|
||||
signal={tab.signal} scrollportRef={props.scrollportRef} onPreference={setPreference}>
|
||||
{pages}
|
||||
</ZoomViewport>
|
||||
}
|
||||
|
||||
function PdfPage({ document, page, requested: initiallyRequested, onVisible, signal, t }: {
|
||||
function PdfPage({ document, page, requested: initiallyRequested, onVisible, signal, onWidth, zoomSurfaceClass, t }: {
|
||||
readonly document: PdfDocument
|
||||
readonly page: number
|
||||
readonly requested: boolean
|
||||
readonly onVisible: (page: number) => void
|
||||
readonly signal: AbortSignal
|
||||
readonly onWidth: ((width: number) => void) | undefined
|
||||
readonly zoomSurfaceClass: string
|
||||
} & PropsLocale<'sidebarPdf'>): ReactNode {
|
||||
const host = useRef<HTMLDivElement>(null)
|
||||
const canvas = useRef<HTMLCanvasElement>(null)
|
||||
@@ -134,11 +156,11 @@ function PdfPage({ document, page, requested: initiallyRequested, onVisible, sig
|
||||
textTask = createText(pdfPage, viewport)
|
||||
return textTask
|
||||
}).then(
|
||||
(size) => { if (!renderSignal.aborted) { setWidth(size.width); setState('ready') } },
|
||||
(size) => { if (!renderSignal.aborted) { setWidth(size.width); onWidth?.(size.width); setState('ready') } },
|
||||
(error: unknown) => { if (!renderSignal.aborted) setFailure({ error }) },
|
||||
)
|
||||
return () => { lifetime.abort(); textTask?.cancel() }
|
||||
}, [document, page, requested, signal, attempt])
|
||||
}, [document, page, requested, signal, attempt, onWidth])
|
||||
return <div ref={host} className={css.page} data-pdf-page={page}>
|
||||
{failure === undefined && state !== 'ready' && (requested
|
||||
? <div className={css.placeholder} role="status" aria-label={t('rendering')} />
|
||||
@@ -147,7 +169,9 @@ function PdfPage({ document, page, requested: initiallyRequested, onVisible, sig
|
||||
<span>{failureText(failure.error, t)}</span>
|
||||
<Button size="sm" onClick={() => { setAttempt(value => value + 1) }}>{t('retry')}</Button>
|
||||
</div>}
|
||||
<div className={css.surface} style={{ width }} hidden={state !== 'ready' || failure !== undefined}>
|
||||
<div className={`${css.surface} ${zoomSurfaceClass}`} data-document-zoom-surface
|
||||
style={{ '--document-zoom-width': `${width ?? 0}px` } as CSSProperties}
|
||||
hidden={state !== 'ready' || failure !== undefined}>
|
||||
<canvas ref={canvas} className={css.canvas} role="img" aria-label={t('pageImage', { page })} />
|
||||
<div ref={text} className={css.text} data-pdf-text />
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/** Restorable PDF viewing preferences; document objects and canvases remain component-local. */
|
||||
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
|
||||
import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
|
||||
import type { ZoomPreference } from '../zoom/types.ts'
|
||||
|
||||
/** One tab's last visible page. */
|
||||
/** One tab's last visible page and optional PDF zoom preference. */
|
||||
export interface PdfView {
|
||||
readonly page: number
|
||||
readonly zoom?: ZoomPreference
|
||||
}
|
||||
|
||||
/** Page state isolated by the owning tab record. */
|
||||
@@ -14,11 +16,12 @@ export interface PdfState {
|
||||
|
||||
type PdfActions = {
|
||||
page: (draft: PdfState, tabId: TabId, page: number) => void
|
||||
zoom: (draft: PdfState, tabId: TabId, zoom: ZoomPreference) => void
|
||||
forget: (draft: PdfState, tabId: TabId) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare the last visible page isolated by tab identity.
|
||||
* Declare the last visible page and zoom preference isolated by tab identity.
|
||||
* @returns a store declaration instantiated by the document slot for each Session.
|
||||
*/
|
||||
export function createPdfStore(): EngineStoreHandle<PdfState, PdfActions> {
|
||||
@@ -27,7 +30,11 @@ export function createPdfStore(): EngineStoreHandle<PdfState, PdfActions> {
|
||||
actions: {
|
||||
/** @param draft - view state. @param tabId - owning tab. @param page - selected 1-based page. */
|
||||
page: (draft, tabId: TabId, page: number) => {
|
||||
draft.byTab[tabId] = { page }
|
||||
draft.byTab[tabId] = { ...draft.byTab[tabId], page }
|
||||
},
|
||||
/** @param draft - view state. @param tabId - owning tab. @param zoom - selected scale multiplier. */
|
||||
zoom: (draft, tabId: TabId, zoom: ZoomPreference) => {
|
||||
draft.byTab[tabId] = { page: draft.byTab[tabId]?.page ?? 1, zoom }
|
||||
},
|
||||
/** @param draft - view state. @param tabId - closed tab whose preferences are discarded. */
|
||||
forget: (draft, tabId: TabId) => {
|
||||
|
||||
@@ -91,15 +91,3 @@ export type DocumentFileBytes = WorkspaceFileBytes<Uint8Array<ArrayBuffer>>
|
||||
* @returns complete native bytes, including declared failures.
|
||||
*/
|
||||
export type ReadDocumentBytes = (file: SessionFile, signal: AbortSignal) => Promise<RemoteResult<DocumentFileBytes>>
|
||||
|
||||
/**
|
||||
* Decode the Office conversion Remote's base64 PDF response.
|
||||
* @param file - converted PDF metadata and base64 contents.
|
||||
* @returns the same metadata with native bytes; malformed base64 throws.
|
||||
*/
|
||||
export function documentFileBytes(file: Omit<WorkspaceFileBytes, 'data'> & { readonly data: string }): DocumentFileBytes {
|
||||
const binary = atob(file.data)
|
||||
const data = new Uint8Array(binary.length)
|
||||
for (let index = 0; index < binary.length; index++) data[index] = binary.charCodeAt(index)
|
||||
return { ...file, data }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
.panel {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset-inline-start: 50%;
|
||||
bottom: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 6px;
|
||||
border: 0.5px solid var(--dsw-alias-border-l3);
|
||||
border-radius: 12px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
box-shadow: 0 8px 24px light-dark(#0000001a, #00000060), 0 2px 5px light-dark(#00000009, #00000035);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, 16px) scale(0.98);
|
||||
transition: opacity 160ms ease-in, transform 160ms cubic-bezier(0.4, 0, 1, 1);
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translate(-50%, 0) scale(1);
|
||||
transition: opacity 140ms ease-out, transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.controlAnchor { display: flex; }
|
||||
.zoomButton { width: 28px; padding: 0; flex: none; }
|
||||
.percent { display: inline-flex; align-items: center; justify-content: center; gap: 2px; min-width: 58px; padding-inline: 6px; font-variant-numeric: tabular-nums; }
|
||||
.zoomMenu { min-width: 112px; }
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.panel {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translate(-50%, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.panel,
|
||||
.visible { transition-duration: 0.01ms; }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/** Floating controls shared by every zoomable document renderer. */
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState, type ReactNode } from 'react'
|
||||
import { Button, IconChevronDownOutlineRegular, IconPlusOutlineRegular, Menu, Tooltip,
|
||||
type MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { MAX_FIXED_ZOOM, MIN_FIXED_ZOOM, ZOOM_OPTIONS, ZOOM_STEP, type ZoomLabels } from './types.ts'
|
||||
import css from './ZoomControls.module.css'
|
||||
|
||||
function steppedZoom(zoom: number, direction: -1 | 1): number {
|
||||
const step = direction === -1 ? Math.ceil(zoom / ZOOM_STEP) - 1 : Math.floor(zoom / ZOOM_STEP) + 1
|
||||
return Math.min(MAX_FIXED_ZOOM, Math.max(MIN_FIXED_ZOOM, step * ZOOM_STEP))
|
||||
}
|
||||
|
||||
/** Live percentage display updated independently from the document tree. */
|
||||
export interface ZoomControlsHandle {
|
||||
/** @param zoom - current gesture scale multiplier. */
|
||||
showZoom: (zoom: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Present fit-width, fixed presets, and incremental controls.
|
||||
* @param props - current resolved zoom, preference mode, labels, and callbacks.
|
||||
* @returns a localized zoom toolbar.
|
||||
*/
|
||||
export const ZoomControls = forwardRef<ZoomControlsHandle, {
|
||||
readonly zoom: number
|
||||
readonly fitWidth: boolean
|
||||
readonly labels: ZoomLabels
|
||||
readonly visible: boolean
|
||||
readonly onZoom: (zoom: number) => void
|
||||
readonly onFitWidth: () => void
|
||||
readonly onActiveChange: (active: boolean) => void
|
||||
}>(function ZoomControls({ zoom, fitWidth, labels, visible, onZoom, onFitWidth, onActiveChange }, ref): ReactNode {
|
||||
const [displayZoom, setDisplayZoom] = useState(zoom)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const [focused, setFocused] = useState(false)
|
||||
useImperativeHandle(ref, () => ({ showZoom: setDisplayZoom }), [])
|
||||
useEffect(() => { onActiveChange(open || hovered || focused) }, [focused, hovered, onActiveChange, open])
|
||||
const items: readonly MenuEntry[] = [
|
||||
{ id: 'fit-width', label: labels.fitWidth },
|
||||
{ type: 'separator', id: 'fit-scale' },
|
||||
...ZOOM_OPTIONS.map(value => ({ id: String(value), label: labels.value(value * 100) })),
|
||||
]
|
||||
const selectedId = fitWidth ? 'fit-width'
|
||||
: ZOOM_OPTIONS.includes(displayZoom as typeof ZOOM_OPTIONS[number]) ? String(displayZoom) : undefined
|
||||
return <div className={`${css.panel} ${visible ? css.visible : ''}`} role="toolbar" aria-label={labels.controls}
|
||||
data-document-zoom-controls data-document-zoom-visible={visible || undefined}
|
||||
onPointerEnter={() => { setHovered(true) }} onPointerLeave={() => { setHovered(false) }}
|
||||
onFocusCapture={() => { setFocused(true) }} onBlurCapture={() => { setFocused(false) }}>
|
||||
<Tooltip label={labels.out} side="top">
|
||||
<span className={css.controlAnchor}><Button size="sm" className={css.zoomButton} aria-label={labels.out}
|
||||
disabled={displayZoom <= MIN_FIXED_ZOOM} onMouseDown={(event) => { event.preventDefault() }}
|
||||
onClick={() => { onZoom(steppedZoom(displayZoom, -1)) }}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M2 8h12" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
||||
</svg>
|
||||
</Button></span>
|
||||
</Tooltip>
|
||||
<Menu open={open} side="top" align="start" portal compact listClassName={css.zoomMenu}
|
||||
anchor={<Button size="sm" className={css.percent} aria-label={labels.menu} aria-expanded={open}
|
||||
onMouseDown={(event) => { event.preventDefault() }} onClick={() => { setOpen(value => !value) }}>
|
||||
<span>{labels.value(Math.round(displayZoom * 100))}</span>
|
||||
<IconChevronDownOutlineRegular size={12} />
|
||||
</Button>}
|
||||
items={items} selectedId={selectedId} onClose={() => { setOpen(false) }} onSelect={(selected) => {
|
||||
setOpen(false)
|
||||
if (selected === 'fit-width') onFitWidth()
|
||||
else onZoom(Number(selected))
|
||||
}} />
|
||||
<Tooltip label={labels.into} side="top">
|
||||
<span className={css.controlAnchor}><Button size="sm" className={css.zoomButton} aria-label={labels.into}
|
||||
disabled={displayZoom >= MAX_FIXED_ZOOM} onMouseDown={(event) => { event.preventDefault() }}
|
||||
onClick={() => { onZoom(steppedZoom(displayZoom, 1)) }}>
|
||||
<IconPlusOutlineRegular size={14} />
|
||||
</Button></span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
.frame {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scrollport {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.surface {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.revealZone {
|
||||
position: absolute;
|
||||
inset: auto 0 0;
|
||||
height: var(--document-zoom-reveal-height);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.frame[data-document-zoom-mode="fit-width"] .surface {
|
||||
width: min(var(--document-zoom-width), 100%);
|
||||
}
|
||||
|
||||
.frame[data-document-zoom-mode="fixed"] .surface {
|
||||
width: calc(var(--document-zoom-width) * var(--document-zoom));
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/** Shared zoom viewport, gesture handling, anchoring, and fit-width measurement. */
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode, type RefCallback } from 'react'
|
||||
import { ZoomControls, type ZoomControlsHandle } from './ZoomControls.tsx'
|
||||
import { FIT_WIDTH, MAX_FIXED_ZOOM, MIN_FIXED_ZOOM, type ZoomLabels, type ZoomPreference } from './types.ts'
|
||||
import css from './ZoomViewport.module.css'
|
||||
|
||||
const PINCH_SETTLE_DELAY_MS = 120
|
||||
const CONTROLS_HIDE_DELAY_MS = 420
|
||||
const CONTROLS_REVEAL_HEIGHT_PX = 72
|
||||
|
||||
interface PointerAnchor {
|
||||
readonly surface: HTMLElement
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
readonly clientX: number
|
||||
readonly clientY: number
|
||||
}
|
||||
|
||||
function pointerAnchor(clientX: number, clientY: number): PointerAnchor | undefined {
|
||||
const lookup: unknown = Reflect.get(document, 'elementFromPoint')
|
||||
if (typeof lookup !== 'function') return undefined
|
||||
const hit: unknown = Reflect.apply(lookup, document, [clientX, clientY])
|
||||
const surface = hit instanceof Element ? hit.closest<HTMLElement>('[data-document-zoom-surface]') : null
|
||||
if (surface === null) return undefined
|
||||
const bounds = surface.getBoundingClientRect()
|
||||
return { surface, x: clientX - bounds.left, y: clientY - bounds.top, clientX, clientY }
|
||||
}
|
||||
|
||||
/** @returns an actual-size scale that only shrinks content wider than the viewport. */
|
||||
export function fitWidthZoom(viewportWidth: number, intrinsicWidth: number | undefined, horizontalInset = 0): number {
|
||||
if (intrinsicWidth === undefined || intrinsicWidth <= 0) return 1
|
||||
return Math.min(1, Math.max(1, viewportWidth - horizontalInset) / intrinsicWidth)
|
||||
}
|
||||
|
||||
/** Shared viewport inputs for renderer-owned content and tab state. */
|
||||
export interface ZoomViewportProps {
|
||||
readonly preference: ZoomPreference
|
||||
readonly intrinsicWidth: number | undefined
|
||||
readonly horizontalInset?: number
|
||||
readonly labels: ZoomLabels
|
||||
readonly signal: AbortSignal
|
||||
readonly scrollportRef: RefCallback<HTMLElement>
|
||||
readonly onPreference: (preference: ZoomPreference) => void
|
||||
readonly children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep zoom controls and trackpad gestures independent from document rendering.
|
||||
* @param props - renderer content, intrinsic width, tab preference, and localized labels.
|
||||
* @returns the owned scrollport and floating zoom controls.
|
||||
*/
|
||||
export function ZoomViewport(props: ZoomViewportProps): ReactNode {
|
||||
const [viewportWidth, setViewportWidth] = useState(0)
|
||||
const [controlsVisible, setControlsVisible] = useState(false)
|
||||
const fitZoom = fitWidthZoom(viewportWidth, props.intrinsicWidth, props.horizontalInset)
|
||||
const zoom = props.preference.kind === 'fit-width' ? fitZoom : props.preference.scale
|
||||
const zoomRef = useRef(zoom)
|
||||
const modeRef = useRef(props.preference.kind)
|
||||
const frame = useRef<HTMLElement | null>(null)
|
||||
const scrollport = useRef<HTMLDivElement | null>(null)
|
||||
const controls = useRef<ZoomControlsHandle>(null)
|
||||
const pinchTimer = useRef<ReturnType<typeof setTimeout>>()
|
||||
const controlsHideTimer = useRef<ReturnType<typeof setTimeout>>()
|
||||
const pointerInRevealZone = useRef(false)
|
||||
const controlsActive = useRef(false)
|
||||
const clearControlsHide = useCallback((): void => {
|
||||
clearTimeout(controlsHideTimer.current)
|
||||
controlsHideTimer.current = undefined
|
||||
}, [])
|
||||
const showControls = useCallback((): void => {
|
||||
clearControlsHide()
|
||||
setControlsVisible(true)
|
||||
}, [clearControlsHide])
|
||||
const scheduleControlsHide = useCallback((): void => {
|
||||
clearControlsHide()
|
||||
if (pointerInRevealZone.current || controlsActive.current || pinchTimer.current !== undefined) return
|
||||
controlsHideTimer.current = setTimeout(() => {
|
||||
controlsHideTimer.current = undefined
|
||||
setControlsVisible(false)
|
||||
}, CONTROLS_HIDE_DELAY_MS)
|
||||
}, [clearControlsHide])
|
||||
const handleControlsActive = useCallback((active: boolean): void => {
|
||||
controlsActive.current = active
|
||||
if (active) showControls()
|
||||
else scheduleControlsHide()
|
||||
}, [scheduleControlsHide, showControls])
|
||||
const handlePointerMove = useCallback((event: ReactPointerEvent<HTMLElement>): void => {
|
||||
const inRevealZone = event.pointerType === 'mouse'
|
||||
&& event.clientY >= event.currentTarget.getBoundingClientRect().bottom - CONTROLS_REVEAL_HEIGHT_PX
|
||||
pointerInRevealZone.current = inRevealZone
|
||||
if (inRevealZone) showControls()
|
||||
else scheduleControlsHide()
|
||||
}, [scheduleControlsHide, showControls])
|
||||
const handlePointerLeave = useCallback((): void => {
|
||||
pointerInRevealZone.current = false
|
||||
scheduleControlsHide()
|
||||
}, [scheduleControlsHide])
|
||||
const attachFrame = useCallback((node: HTMLElement | null): void => {
|
||||
frame.current = node
|
||||
node?.style.setProperty('--document-zoom', String(zoomRef.current))
|
||||
}, [])
|
||||
const attachScrollport = useCallback((node: HTMLDivElement | null): void => {
|
||||
scrollport.current = node
|
||||
props.scrollportRef(node)
|
||||
}, [props.scrollportRef])
|
||||
const showZoom = useCallback((next: number, clientX?: number, clientY?: number): number => {
|
||||
const node = scrollport.current as HTMLDivElement
|
||||
const previous = zoomRef.current
|
||||
next = Math.min(MAX_FIXED_ZOOM, Math.max(MIN_FIXED_ZOOM, next))
|
||||
if (next === previous) return previous
|
||||
const bounds = node.getBoundingClientRect()
|
||||
const x = clientX === undefined ? node.clientWidth / 2 : clientX - bounds.left
|
||||
const y = clientY === undefined ? node.clientHeight / 2 : clientY - bounds.top
|
||||
const anchor = pointerAnchor(bounds.left + x, bounds.top + y)
|
||||
const previousScrollLeft = node.scrollLeft
|
||||
const previousScrollTop = node.scrollTop
|
||||
const ratio = next / previous
|
||||
zoomRef.current = next
|
||||
modeRef.current = 'fixed'
|
||||
frame.current?.setAttribute('data-document-zoom-mode', 'fixed')
|
||||
frame.current?.style.setProperty('--document-zoom', String(next))
|
||||
controls.current?.showZoom(next)
|
||||
if (anchor === undefined) {
|
||||
node.scrollLeft = (previousScrollLeft + x) * ratio - x
|
||||
node.scrollTop = (previousScrollTop + y) * ratio - y
|
||||
} else {
|
||||
const nextBounds = anchor.surface.getBoundingClientRect()
|
||||
node.scrollLeft += nextBounds.left + anchor.x * ratio - anchor.clientX
|
||||
node.scrollTop += nextBounds.top + anchor.y * ratio - anchor.clientY
|
||||
}
|
||||
return next
|
||||
}, [])
|
||||
const setZoom = useCallback((next: number): void => {
|
||||
clearTimeout(pinchTimer.current)
|
||||
pinchTimer.current = undefined
|
||||
props.onPreference({ kind: 'fixed', scale: showZoom(next) })
|
||||
}, [props.onPreference, showZoom])
|
||||
const fitWidth = useCallback((): void => {
|
||||
clearTimeout(pinchTimer.current)
|
||||
pinchTimer.current = undefined
|
||||
const node = frame.current
|
||||
zoomRef.current = fitZoom
|
||||
modeRef.current = 'fit-width'
|
||||
node?.setAttribute('data-document-zoom-mode', 'fit-width')
|
||||
node?.style.setProperty('--document-zoom', String(fitZoom))
|
||||
controls.current?.showZoom(fitZoom)
|
||||
props.onPreference(FIT_WIDTH)
|
||||
}, [fitZoom, props.onPreference])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = scrollport.current as HTMLDivElement
|
||||
const measure = (): void => { setViewportWidth(node.clientWidth) }
|
||||
measure()
|
||||
if (typeof ResizeObserver === 'undefined') return
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(node)
|
||||
return () => { observer.disconnect() }
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (pinchTimer.current !== undefined) return
|
||||
zoomRef.current = zoom
|
||||
modeRef.current = props.preference.kind
|
||||
frame.current?.setAttribute('data-document-zoom-mode', props.preference.kind)
|
||||
frame.current?.style.setProperty('--document-zoom', String(zoom))
|
||||
controls.current?.showZoom(zoom)
|
||||
}, [props.preference.kind, zoom])
|
||||
useEffect(() => {
|
||||
const node = scrollport.current as HTMLDivElement
|
||||
const clearPendingPinch = (): void => {
|
||||
clearTimeout(pinchTimer.current)
|
||||
pinchTimer.current = undefined
|
||||
clearControlsHide()
|
||||
}
|
||||
const wheel = (event: WheelEvent): void => {
|
||||
if (!event.ctrlKey || props.signal.aborted) return
|
||||
event.preventDefault()
|
||||
showControls()
|
||||
const unit = event.deltaMode === WheelEvent.DOM_DELTA_LINE ? 16
|
||||
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? node.clientHeight : 1
|
||||
const delta = Math.max(-40, Math.min(40, event.deltaY * unit))
|
||||
showZoom(zoomRef.current * Math.exp(-delta * 0.01), event.clientX, event.clientY)
|
||||
clearTimeout(pinchTimer.current)
|
||||
pinchTimer.current = setTimeout(() => {
|
||||
pinchTimer.current = undefined
|
||||
props.onPreference({ kind: 'fixed', scale: zoomRef.current })
|
||||
scheduleControlsHide()
|
||||
}, PINCH_SETTLE_DELAY_MS)
|
||||
}
|
||||
node.addEventListener('wheel', wheel, { passive: false })
|
||||
props.signal.addEventListener('abort', clearPendingPinch, { once: true })
|
||||
return () => {
|
||||
node.removeEventListener('wheel', wheel)
|
||||
props.signal.removeEventListener('abort', clearPendingPinch)
|
||||
clearPendingPinch()
|
||||
}
|
||||
}, [clearControlsHide, props.onPreference, props.signal, scheduleControlsHide, showControls, showZoom])
|
||||
|
||||
return <section ref={attachFrame} className={css.frame} data-document-zoom-frame
|
||||
data-document-zoom-mode={modeRef.current} style={{ '--document-zoom': zoomRef.current,
|
||||
'--document-zoom-reveal-height': `${String(CONTROLS_REVEAL_HEIGHT_PX)}px` } as CSSProperties}
|
||||
onPointerMove={handlePointerMove} onPointerLeave={handlePointerLeave}>
|
||||
<div ref={attachScrollport} className={css.scrollport} data-document-zoom-scrollport>{props.children}</div>
|
||||
<div className={css.revealZone} data-document-zoom-reveal-zone aria-hidden="true" />
|
||||
{props.intrinsicWidth !== undefined && <ZoomControls ref={controls} zoom={zoomRef.current}
|
||||
fitWidth={modeRef.current === 'fit-width'} labels={props.labels} visible={controlsVisible}
|
||||
onZoom={setZoom} onFitWidth={fitWidth} onActiveChange={handleControlsActive} />}
|
||||
</section>
|
||||
}
|
||||
|
||||
/** Shared surface class for actual-size and fit-width layout. */
|
||||
export const zoomSurfaceClass = css.surface as string
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Shared zoom copy embedded into renderer-owned dictionaries. */
|
||||
export const zoomZh = {
|
||||
zoomControls: '缩放控件',
|
||||
zoomMenu: '选择缩放比例',
|
||||
zoomOut: '缩小',
|
||||
zoomIn: '放大',
|
||||
zoomFitWidth: '适应宽度',
|
||||
zoomValue: '{percent}%',
|
||||
} as const
|
||||
|
||||
/** Shared English zoom copy. */
|
||||
export const zoomEn = {
|
||||
zoomControls: 'Zoom controls',
|
||||
zoomMenu: 'Choose zoom',
|
||||
zoomOut: 'Zoom out',
|
||||
zoomIn: 'Zoom in',
|
||||
zoomFitWidth: 'Fit width',
|
||||
zoomValue: '{percent}%',
|
||||
} satisfies Record<keyof typeof zoomZh, string>
|
||||
|
||||
/** Shared zoom dictionary keys. */
|
||||
export type ZoomLocaleKey = keyof typeof zoomZh
|
||||
@@ -0,0 +1,43 @@
|
||||
/** Tab-local zoom preferences for renderers without additional view state. */
|
||||
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
|
||||
import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
|
||||
import { FIT_WIDTH, type ZoomPreference } from './types.ts'
|
||||
|
||||
/** Tab-lifetime callback supplied by a zoomable renderer registration. */
|
||||
export interface ZoomInjected {
|
||||
/** @param tabId - owning tab. @param signal - complete tab-record lifetime. */
|
||||
readonly retainTab: (tabId: TabId, signal: AbortSignal) => void
|
||||
}
|
||||
|
||||
/** Zoom state isolated by the owning tab record. */
|
||||
export interface ZoomState {
|
||||
byTab: Record<TabId, ZoomPreference>
|
||||
}
|
||||
|
||||
type ZoomActions = {
|
||||
zoom: (draft: ZoomState, tabId: TabId, preference: ZoomPreference) => void
|
||||
forget: (draft: ZoomState, tabId: TabId) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Create tab-local zoom state for a document renderer registration.
|
||||
* @returns a store declaration retaining zoom until its tab closes.
|
||||
*/
|
||||
export function createZoomStore(): EngineStoreHandle<ZoomState, ZoomActions> {
|
||||
return defineStore({
|
||||
init: (): ZoomState => ({ byTab: {} }),
|
||||
actions: {
|
||||
zoom: (draft, tabId: TabId, preference: ZoomPreference) => { draft.byTab[tabId] = preference },
|
||||
forget: (draft, tabId: TabId) => {
|
||||
const { [tabId]: _closed, ...remaining } = draft.byTab
|
||||
draft.byTab = remaining
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Default zoom value for a tab without retained state. */
|
||||
export const DEFAULT_ZOOM = FIT_WIDTH
|
||||
|
||||
/** Store declaration used by image previews. */
|
||||
export type ZoomStore = ReturnType<typeof createZoomStore>
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Automatic fit or an explicit actual-size scale selected by the reader. */
|
||||
export type ZoomPreference =
|
||||
| { readonly kind: 'fit-width' }
|
||||
| { readonly kind: 'fixed'; readonly scale: number }
|
||||
|
||||
/** Default preference for a newly opened zoomable preview. */
|
||||
export const FIT_WIDTH: ZoomPreference = { kind: 'fit-width' }
|
||||
|
||||
/** Minimum scale retained by a fixed zoom preference. */
|
||||
export const MIN_FIXED_ZOOM = 0.25
|
||||
|
||||
/** Maximum scale retained by a fixed zoom preference. */
|
||||
export const MAX_FIXED_ZOOM = 4
|
||||
|
||||
/** Scale interval used by the incremental controls. */
|
||||
export const ZOOM_STEP = 0.25
|
||||
|
||||
/** Fixed scale choices shown in the zoom menu. */
|
||||
export const ZOOM_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const
|
||||
|
||||
/** Localized copy consumed by the framework-free zoom controls. */
|
||||
export interface ZoomLabels {
|
||||
readonly controls: string
|
||||
readonly menu: string
|
||||
readonly out: string
|
||||
readonly into: string
|
||||
readonly fitWidth: string
|
||||
readonly value: (percent: number) => string
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { expect, it } from 'vitest'
|
||||
import { documentFileBytes } from '../src/client/rpc.ts'
|
||||
|
||||
const source = { absolutePath: '/report.pdf', version: 'v1', offset: 0, eof: true, missingFonts: ['Missing Serif'] }
|
||||
|
||||
it('preserves every byte value, source metadata, font notices, padding, and empty input', () => {
|
||||
for (const size of [0, 1, 2, 256, 257]) {
|
||||
const input = Uint8Array.from({ length: size }, (_, index) => index % 256)
|
||||
expect(documentFileBytes({ ...source, data: Buffer.from(input).toString('base64') }))
|
||||
.toEqual({ ...source, data: input })
|
||||
}
|
||||
expect(() => documentFileBytes({ ...source, data: '!invalid!' })).toThrow()
|
||||
})
|
||||
|
||||
it('decodes a large response within a bounded JavaScript heap', () => {
|
||||
// Typed buffers fit this heap; expanding the binary string into JS elements does not.
|
||||
const decoder = new URL('../src/client/rpc.ts', import.meta.url).href
|
||||
const child = spawnSync(process.execPath, ['--max-old-space-size=96', '--import', 'tsx/esm', '--input-type=module', '-e', `
|
||||
import { documentFileBytes } from ${JSON.stringify(decoder)}
|
||||
const input = Buffer.alloc(12 * 1024 * 1024)
|
||||
for (let index = 0; index < input.length; index++) input[index] = index % 256
|
||||
const output = documentFileBytes({ ...${JSON.stringify(source)}, data: input.toString('base64') }).data
|
||||
if (!Buffer.from(output).equals(input)) throw new Error('Decoded PDF bytes differ')
|
||||
process.stdout.write(String(output.length))
|
||||
`], { encoding: 'utf8', timeout: 30_000, maxBuffer: 1024 * 1024, env: { ...process.env, NODE_OPTIONS: '' } })
|
||||
expect(child.error).toBeUndefined()
|
||||
expect(child.signal, child.stderr).toBeNull()
|
||||
expect(child.status, child.stderr).toBe(0)
|
||||
expect(child.stdout).toBe(String(12 * 1024 * 1024))
|
||||
})
|
||||
@@ -1,10 +1,13 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Image Blob ownership, media types, pane-contained rendering, and failure states. */
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
|
||||
import { ImageBody, imageMediaType, type ImageBodyProps } from '../src/client/image/ImageBody.tsx'
|
||||
import { en } from '../src/client/image/locales.ts'
|
||||
import { createZoomStore, type ZoomState } from '../src/client/zoom/store.ts'
|
||||
|
||||
const translations: ReadonlyMap<string, string> = new Map(Object.entries(en))
|
||||
let createDescriptor: PropertyDescriptor | undefined
|
||||
@@ -13,6 +16,7 @@ const create = vi.fn<(blob: Blob) => string>()
|
||||
const revoke = vi.fn<(url: string) => void>()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(100)
|
||||
createDescriptor = Object.getOwnPropertyDescriptor(URL, 'createObjectURL')
|
||||
revokeDescriptor = Object.getOwnPropertyDescriptor(URL, 'revokeObjectURL')
|
||||
create.mockReset().mockImplementation(() => `blob:https://preview.invalid/${create.mock.calls.length}`)
|
||||
@@ -27,20 +31,32 @@ afterEach(() => {
|
||||
else Object.defineProperty(URL, 'createObjectURL', createDescriptor)
|
||||
if (revokeDescriptor === undefined) Reflect.deleteProperty(URL, 'revokeObjectURL')
|
||||
else Object.defineProperty(URL, 'revokeObjectURL', revokeDescriptor)
|
||||
vi.restoreAllMocks()
|
||||
}
|
||||
})
|
||||
|
||||
function props(path = 'asset.png', data: Uint8Array<ArrayBuffer> = new Uint8Array([1, 2, 3])): ImageBodyProps {
|
||||
function props(path = 'asset.png', data: Uint8Array<ArrayBuffer> = new Uint8Array([1, 2, 3]),
|
||||
instance = createZoomStore().create(), controller = new AbortController()): ImageBodyProps {
|
||||
const subscribe = (listener: () => void) => instance.subscribe(listener)
|
||||
const snapshot = () => instance.getSnapshot()
|
||||
function useStore<T>(selector: (state: ZoomState) => T): T {
|
||||
return selector(useSyncExternalStore(subscribe, snapshot))
|
||||
}
|
||||
return {
|
||||
resourceAddress: `dsh-resource://file/session/image/${path}`,
|
||||
content: { kind: 'bytes', data },
|
||||
wrap: false,
|
||||
sessionId: 'image' as SessionId,
|
||||
useTabInfo: () => ({ tab: { signal: new AbortController().signal } }),
|
||||
useTabInfo: () => ({ tab: { id: 'image-tab' as TabId, signal: controller.signal } }),
|
||||
useResource: () => ({ value: undefined }),
|
||||
useStore,
|
||||
actions: instance.actions,
|
||||
retainTab: vi.fn(),
|
||||
scrollportRef: vi.fn(),
|
||||
t: (key, params) => {
|
||||
const value = translations.get(key) ?? key
|
||||
return params === undefined ? value : value.replace('{name}', String(params.name))
|
||||
return params === undefined ? value : Object.entries(params)
|
||||
.reduce((text, [name, replacement]) => text.replace(`{${name}}`, String(replacement)), value)
|
||||
},
|
||||
} as ImageBodyProps
|
||||
}
|
||||
@@ -60,6 +76,9 @@ describe('ImageBody', () => {
|
||||
const image = await screen.findByRole('img', { hidden: true })
|
||||
expect(create.mock.calls[0]?.[0].type).toBe(mediaType)
|
||||
expect(image.getAttribute('src')).toBe('blob:https://preview.invalid/1')
|
||||
Object.defineProperty(image, 'naturalWidth', { configurable: true, value: 10 })
|
||||
fireEvent.load(image)
|
||||
expect(screen.getByRole('toolbar', { name: 'Zoom controls' })).toBeTruthy()
|
||||
view.unmount()
|
||||
expect(revoke).toHaveBeenCalledExactlyOnceWith('blob:https://preview.invalid/1')
|
||||
})
|
||||
@@ -67,14 +86,16 @@ describe('ImageBody', () => {
|
||||
it('keeps the image non-draggable, then shows it after decoding succeeds', async () => {
|
||||
render(<ImageBody {...props('photo.svg')} />)
|
||||
const image = await screen.findByRole('img', { hidden: true })
|
||||
Object.defineProperty(image, 'naturalWidth', { configurable: true, value: 50 })
|
||||
expect(image.getAttribute('alt')).toBe('Image preview: photo.svg')
|
||||
expect(image.getAttribute('decoding')).toBe('async')
|
||||
expect(image.getAttribute('draggable')).toBe('false')
|
||||
expect(image.getAttribute('referrerpolicy')).toBe('no-referrer')
|
||||
expect(image.hasAttribute('hidden')).toBe(true)
|
||||
expect(image.parentElement?.hasAttribute('hidden')).toBe(true)
|
||||
fireEvent.load(image)
|
||||
expect(image.hasAttribute('hidden')).toBe(false)
|
||||
expect(image.parentElement?.hasAttribute('hidden')).toBe(false)
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Choose zoom' }).textContent).toContain('100%')
|
||||
})
|
||||
|
||||
it('revokes replaced bytes and reports image decode and Blob creation failures', async () => {
|
||||
@@ -108,4 +129,25 @@ describe('ImageBody', () => {
|
||||
expect(imageMediaType('folder/no-extension')).toBeUndefined()
|
||||
expect(imageMediaType('folder/photo.avif')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('retains image zoom without recreating the image or Blob URL', async () => {
|
||||
const instance = createZoomStore().create()
|
||||
const initial = props('diagram.svg', new Uint8Array([4, 5, 6]), instance)
|
||||
const view = render(<ImageBody {...initial} />)
|
||||
const image = await screen.findByRole('img', { hidden: true })
|
||||
Object.defineProperty(image, 'naturalWidth', { configurable: true, value: 200 })
|
||||
fireEvent.load(image)
|
||||
expect(screen.getByRole('button', { name: 'Choose zoom' }).textContent).toContain('38%')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose zoom' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '150%' }))
|
||||
expect(instance.getSnapshot().byTab['image-tab' as TabId]).toEqual({ kind: 'fixed', scale: 1.5 })
|
||||
expect(screen.getByRole('img')).toBe(image)
|
||||
expect(create).toHaveBeenCalledOnce()
|
||||
view.rerender(<ImageBody {...props('diagram.svg', initial.content.kind === 'bytes' ? initial.content.data : new Uint8Array(), instance)} />)
|
||||
expect(screen.getByRole('img')).toBe(image)
|
||||
expect(screen.getByRole('button', { name: 'Choose zoom' }).textContent).toContain('150%')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose zoom' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Fit width' }))
|
||||
expect(instance.getSnapshot().byTab['image-tab' as TabId]).toEqual({ kind: 'fit-width' })
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,13 @@
|
||||
/** Image metadata, keyed slot, dictionary, and disposal registration. */
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
|
||||
import { DocumentPreviewRegistry } from '../src/client/document/registry.ts'
|
||||
import { ImageBody } from '../src/client/image/ImageBody.tsx'
|
||||
import { apply, BINARY_IMAGE_EXTENSIONS, IMAGE_BODY_ID, IMAGE_EXTENSIONS, imageBodyDefinition } from '../src/client/image/index.ts'
|
||||
import { en, zh } from '../src/client/image/locales.ts'
|
||||
import type { ZoomInjected, ZoomStore } from '../src/client/zoom/store.ts'
|
||||
|
||||
let dispose: (() => Promise<void>) | undefined
|
||||
afterEach(async () => { await dispose?.(); dispose = undefined })
|
||||
@@ -31,9 +34,18 @@ describe('image registration', () => {
|
||||
const registry = new DocumentPreviewRegistry()
|
||||
const dictionaries = new Map<string, unknown>()
|
||||
const bodies = new Map<string, unknown>()
|
||||
const register = vi.fn((options: { key: string }, body: unknown) => {
|
||||
type Options = {
|
||||
name: string
|
||||
key: string
|
||||
locale: string
|
||||
store: ZoomStore
|
||||
inject: (sessionId: SessionId, actions: ReturnType<ZoomStore['create']>['actions']) => ZoomInjected
|
||||
}
|
||||
const entries: Options[] = []
|
||||
const register = vi.fn((options: Options, body: unknown) => {
|
||||
bodies.set(options.key, body)
|
||||
return () => { bodies.delete(options.key) }
|
||||
entries.push(options)
|
||||
return () => { bodies.delete(options.key); entries.splice(entries.indexOf(options), 1) }
|
||||
})
|
||||
ctx.provide('documentPreviews', registry)
|
||||
ctx.provide('slots', { inject: (_key: string, callback: () => () => void) => callback(), register } as never)
|
||||
@@ -52,13 +64,22 @@ describe('image registration', () => {
|
||||
}
|
||||
expect(registry.getSnapshot()[0]?.title()).toBe(en.title)
|
||||
expect(dictionaries.get('sidebarImage')).toEqual({ zh, en })
|
||||
expect(register).toHaveBeenCalledExactlyOnceWith(
|
||||
{ name: 'sidebar.right.tab.document', key: IMAGE_BODY_ID, locale: 'sidebarImage' },
|
||||
ImageBody,
|
||||
)
|
||||
expect(register).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
|
||||
name: 'sidebar.right.tab.document', key: IMAGE_BODY_ID, locale: 'sidebarImage',
|
||||
}), ImageBody)
|
||||
expect(entries[0]?.store).toBeDefined()
|
||||
expect(typeof entries[0]?.inject).toBe('function')
|
||||
const instance = entries[0]!.store.create()
|
||||
const face = entries[0]!.inject('image-session' as SessionId, instance.actions)
|
||||
const controller = new AbortController()
|
||||
face.retainTab('image-tab' as TabId, controller.signal)
|
||||
instance.actions.zoom('image-tab' as TabId, { kind: 'fixed', scale: 1.5 })
|
||||
controller.abort()
|
||||
expect(instance.getSnapshot().byTab).toEqual({})
|
||||
await dispose()
|
||||
expect(registry.getSnapshot()).toEqual([])
|
||||
expect(bodies.size).toBe(0)
|
||||
expect(entries).toEqual([])
|
||||
expect(dictionaries.size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -23,8 +23,8 @@ vi.mock('../src/client/office/face.ts', async (importOriginal) => {
|
||||
|
||||
const file = { sessionId: 's1' as SessionId, path: 'report.DOCX' }
|
||||
const generation = ('renderer' as OfficeToPdfGeneration)
|
||||
const source = { absolutePath: '/report.docx', version: 'v1', offset: 0, eof: true, bytes: 4, data: 'JVBERg==' }
|
||||
const pdf = new Uint8Array([37, 80, 68, 70])
|
||||
const source = { absolutePath: '/report.docx', version: 'v1', offset: 0, eof: true, bytes: 4, data: pdf }
|
||||
const converted = { ok: true as const, value: { ...source, generation, missingFonts: ['Missing Serif'] } }
|
||||
|
||||
it('retains Office view state across remounts and releases it on tab close or plugin disposal', async () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { PdfBody, type PdfBodyProps } from '../src/client/pdf/pdf.tsx'
|
||||
import { createPdfStore, type PdfState } from '../src/client/pdf/store.ts'
|
||||
import { en } from '../src/client/pdf/locales.ts'
|
||||
import { PdfWorkerFailure } from '../src/client/pdf/errors.ts'
|
||||
import { ZoomViewport, zoomSurfaceClass } from '../src/client/zoom/ZoomViewport.tsx'
|
||||
|
||||
const loads: Array<{
|
||||
deferred: ReturnType<typeof Promise.withResolvers<PdfDocument>>
|
||||
@@ -26,6 +27,7 @@ const loads: Array<{
|
||||
}> = []
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(100)
|
||||
loads.length = 0
|
||||
engine.open.mockReset().mockImplementation(() => {
|
||||
const deferred = Promise.withResolvers<PdfDocument>()
|
||||
@@ -41,7 +43,9 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
class IntersectionObserverStub {
|
||||
@@ -71,7 +75,11 @@ function harness() {
|
||||
function useStore<T>(selector: (state: PdfState) => T): T {
|
||||
return selector(useSyncExternalStore(subscribe, snapshot))
|
||||
}
|
||||
function View({ data = 'one', kind = 'bytes' }: { readonly data?: string; readonly kind?: 'bytes' | 'text' }) {
|
||||
const scrollportRef = vi.fn()
|
||||
function View({ data = 'one', kind = 'bytes' }: {
|
||||
readonly data?: string
|
||||
readonly kind?: 'bytes' | 'text'
|
||||
}) {
|
||||
const bytes = useMemo(() => new TextEncoder().encode(data), [data])
|
||||
// The PDF body reads these standard seats; the remaining framework seats are unused here.
|
||||
const props = {
|
||||
@@ -80,9 +88,9 @@ function harness() {
|
||||
useTabInfo: () => ({ tab: { id: tabId, signal: controller.signal } }),
|
||||
useStore, actions: instance.actions, retainTab: vi.fn(), t: makeTranslate(en),
|
||||
} as unknown as PdfBodyProps
|
||||
return <PdfBody {...props} />
|
||||
return <PdfBody {...props} scrollportRef={scrollportRef} ZoomViewport={ZoomViewport} zoomSurfaceClass={zoomSurfaceClass} />
|
||||
}
|
||||
return { instance, controller, tabId, View }
|
||||
return { instance, controller, tabId, scrollportRef, View }
|
||||
}
|
||||
|
||||
const documentOf = (numPages = 3): PdfDocument => ({ numPages, getPage: vi.fn() })
|
||||
@@ -102,7 +110,7 @@ describe('PDF body', () => {
|
||||
expect(engine.open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows loading, omits the paging toolbar, and renders a continuous page sequence', async () => {
|
||||
it('shows loading, adds zoom controls, and renders a continuous page sequence', async () => {
|
||||
const h = harness()
|
||||
const view = render(<h.View />)
|
||||
expect(screen.getByRole('status').getAttribute('aria-label')).toBe('Reading…')
|
||||
@@ -110,7 +118,7 @@ describe('PDF body', () => {
|
||||
expect(screen.getByRole('status').querySelector('[data-state="ongoing"]')).not.toBeNull()
|
||||
await act(async () => { loads[0]!.deferred.resolve(documentOf()) })
|
||||
await act(async () => {})
|
||||
expect(view.container.querySelector('[role="toolbar"]')).toBeNull()
|
||||
expect(screen.getByRole('toolbar', { name: 'Zoom controls' })).toBeTruthy()
|
||||
expect([...view.container.querySelectorAll('[data-pdf-page]')].map(page => page.getAttribute('data-pdf-page')))
|
||||
.toEqual(['1', '2', '3'])
|
||||
expect(screen.getAllByRole('img').map(image => image.getAttribute('aria-label')))
|
||||
@@ -118,6 +126,117 @@ describe('PDF body', () => {
|
||||
expect(engine.render.mock.calls.map(([, page]) => page)).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('offers retained PDF zoom presets', async () => {
|
||||
const h = harness()
|
||||
const view = render(<h.View />)
|
||||
await act(async () => { loads[0]!.deferred.resolve(documentOf(1)) })
|
||||
expect(h.scrollportRef).toHaveBeenLastCalledWith(view.container.querySelector('[data-document-zoom-scrollport]'))
|
||||
expect(screen.getByRole('toolbar', { name: 'Zoom controls' })).toBeTruthy()
|
||||
const menu = screen.getByRole('button', { name: 'Choose zoom' })
|
||||
expect(fireEvent.mouseDown(menu)).toBe(false)
|
||||
fireEvent.click(menu)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '100%' }))
|
||||
fireEvent.click(menu)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '150%' }))
|
||||
expect(h.instance.getSnapshot().byTab[h.tabId]).toEqual({ page: 1, zoom: { kind: 'fixed', scale: 1.5 } })
|
||||
expect((view.container.querySelector('[data-document-zoom-frame]') as HTMLElement)
|
||||
.style.getPropertyValue('--document-zoom')).toBe('1.5')
|
||||
const out = screen.getByRole('button', { name: 'Zoom out' })
|
||||
expect(fireEvent.mouseDown(out)).toBe(false)
|
||||
fireEvent.click(out)
|
||||
expect(h.instance.getSnapshot().byTab[h.tabId]?.zoom).toEqual({ kind: 'fixed', scale: 1.25 })
|
||||
const into = screen.getByRole('button', { name: 'Zoom in' })
|
||||
expect(fireEvent.mouseDown(into)).toBe(false)
|
||||
fireEvent.click(into)
|
||||
expect(h.instance.getSnapshot().byTab[h.tabId]?.zoom).toEqual({ kind: 'fixed', scale: 1.5 })
|
||||
act(() => { h.instance.actions.zoom(h.tabId, { kind: 'fixed', scale: 1.657 }) })
|
||||
fireEvent.click(out)
|
||||
expect(h.instance.getSnapshot().byTab[h.tabId]?.zoom).toEqual({ kind: 'fixed', scale: 1.5 })
|
||||
act(() => { h.instance.actions.zoom(h.tabId, { kind: 'fixed', scale: 1.657 }) })
|
||||
fireEvent.click(into)
|
||||
expect(h.instance.getSnapshot().byTab[h.tabId]?.zoom).toEqual({ kind: 'fixed', scale: 1.75 })
|
||||
fireEvent.click(menu)
|
||||
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
act(() => { h.instance.actions.zoom(h.tabId, { kind: 'fixed', scale: 4 }) })
|
||||
expect(screen.getByRole('button', { name: 'Zoom in' }).hasAttribute('disabled')).toBe(true)
|
||||
act(() => { h.instance.actions.zoom(h.tabId, { kind: 'fixed', scale: 0.25 }) })
|
||||
expect(screen.getByRole('button', { name: 'Zoom out' }).hasAttribute('disabled')).toBe(true)
|
||||
view.rerender(<h.View />)
|
||||
expect(screen.getByRole('button', { name: 'Choose zoom' }).textContent).toContain('25%')
|
||||
fireEvent.click(menu)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Fit width' }))
|
||||
expect(h.instance.getSnapshot().byTab[h.tabId]?.zoom).toEqual({ kind: 'fit-width' })
|
||||
})
|
||||
|
||||
it('zooms a trackpad gesture around its pointer and leaves ordinary scrolling alone', async () => {
|
||||
const h = harness()
|
||||
const view = render(<h.View />)
|
||||
await act(async () => { loads[0]!.deferred.resolve(documentOf(1)) })
|
||||
const scrollport = view.container.querySelector('[data-document-zoom-scrollport]') as HTMLDivElement
|
||||
Object.defineProperties(scrollport, {
|
||||
clientWidth: { configurable: true, value: 300 },
|
||||
clientHeight: { configurable: true, value: 400 },
|
||||
})
|
||||
scrollport.getBoundingClientRect = () => ({ x: 10, y: 20, left: 10, top: 20, right: 310, bottom: 420,
|
||||
width: 300, height: 400, toJSON: () => ({}) })
|
||||
scrollport.scrollLeft = 40
|
||||
scrollport.scrollTop = 80
|
||||
fireEvent.wheel(scrollport, { deltaY: 20, clientX: 60, clientY: 70 })
|
||||
expect(h.instance.getSnapshot().byTab[h.tabId]?.zoom).toBeUndefined()
|
||||
vi.useFakeTimers()
|
||||
const event = new WheelEvent('wheel', { bubbles: true, cancelable: true, ctrlKey: true,
|
||||
deltaY: -10, clientX: 60, clientY: 70 })
|
||||
const controls = view.container.querySelector('[data-document-zoom-controls]') as HTMLElement
|
||||
expect(controls.hasAttribute('data-document-zoom-visible')).toBe(false)
|
||||
expect(scrollport.dispatchEvent(event)).toBe(false)
|
||||
await act(async () => {})
|
||||
expect(controls.getAttribute('data-document-zoom-visible')).toBe('true')
|
||||
expect(h.instance.getSnapshot().byTab[h.tabId]?.zoom).toBeUndefined()
|
||||
expect(screen.getByRole('button', { name: 'Choose zoom' }).textContent).toContain('111%')
|
||||
expect((view.container.querySelector('[data-document-zoom-frame]') as HTMLElement)
|
||||
.style.getPropertyValue('--document-zoom')).toBe(String(Math.exp(0.1)))
|
||||
expect(scrollport.scrollLeft).toBeCloseTo((40 + 50) * Math.exp(0.1) - 50)
|
||||
expect(scrollport.scrollTop).toBeCloseTo((80 + 50) * Math.exp(0.1) - 50)
|
||||
act(() => { h.instance.actions.zoom(h.tabId, { kind: 'fixed', scale: 1.5 }) })
|
||||
expect(screen.getByRole('button', { name: 'Choose zoom' }).textContent).toContain('111%')
|
||||
expect((view.container.querySelector('[data-document-zoom-frame]') as HTMLElement)
|
||||
.style.getPropertyValue('--document-zoom')).toBe(String(Math.exp(0.1)))
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(120) })
|
||||
const zoom = h.instance.getSnapshot().byTab[h.tabId]?.zoom
|
||||
expect(zoom?.kind).toBe('fixed')
|
||||
if (zoom?.kind !== 'fixed') throw new Error('pinch did not commit a fixed zoom')
|
||||
expect(zoom.scale).toBeCloseTo(Math.exp(0.1))
|
||||
const committed = zoom.scale
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(419) })
|
||||
expect(controls.getAttribute('data-document-zoom-visible')).toBe('true')
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(1) })
|
||||
expect(controls.hasAttribute('data-document-zoom-visible')).toBe(false)
|
||||
scrollport.dispatchEvent(new WheelEvent('wheel', { bubbles: true, cancelable: true, ctrlKey: true,
|
||||
deltaMode: WheelEvent.DOM_DELTA_LINE, deltaY: 1, clientX: 60, clientY: 70 }))
|
||||
await act(async () => {})
|
||||
expect(controls.getAttribute('data-document-zoom-visible')).toBe('true')
|
||||
expect(h.instance.getSnapshot().byTab[h.tabId]?.zoom).toEqual({ kind: 'fixed', scale: committed })
|
||||
view.unmount()
|
||||
expect(h.instance.getSnapshot().byTab[h.tabId]?.zoom).toEqual({ kind: 'fixed', scale: committed })
|
||||
})
|
||||
|
||||
it('does not restore a pending gesture after its tab closes', async () => {
|
||||
vi.useFakeTimers()
|
||||
const h = harness()
|
||||
const view = render(<h.View />)
|
||||
await act(async () => { loads[0]!.deferred.resolve(documentOf(1)) })
|
||||
const scrollport = view.container.querySelector('[data-document-zoom-scrollport]') as HTMLDivElement
|
||||
scrollport.dispatchEvent(new WheelEvent('wheel', { bubbles: true, cancelable: true, ctrlKey: true,
|
||||
deltaMode: WheelEvent.DOM_DELTA_PAGE, deltaY: -1 }))
|
||||
act(() => { h.controller.abort() })
|
||||
scrollport.dispatchEvent(new WheelEvent('wheel', { bubbles: true, cancelable: true, ctrlKey: true, deltaY: -1 }))
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(120) })
|
||||
expect(h.instance.getSnapshot().byTab[h.tabId]).toBeUndefined()
|
||||
view.unmount()
|
||||
expect(h.instance.getSnapshot().byTab[h.tabId]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the replacement document when the previous load settles late', async () => {
|
||||
const h = harness()
|
||||
const mounted = render(<h.View data="old" />)
|
||||
|
||||
@@ -66,8 +66,14 @@ describe('PDF registration', () => {
|
||||
face.retainTab('one' as TabId, controller.signal)
|
||||
face.retainTab('one' as TabId, controller.signal)
|
||||
instance.actions.page('one' as TabId, 2)
|
||||
instance.actions.zoom('one' as TabId, { kind: 'fixed', scale: 1.5 })
|
||||
expect(instance.getSnapshot().byTab['one' as TabId]).toEqual({ page: 2, zoom: { kind: 'fixed', scale: 1.5 } })
|
||||
const kept = new AbortController()
|
||||
face.retainTab('kept' as TabId, kept.signal)
|
||||
instance.actions.page('kept' as TabId, 3)
|
||||
controller.abort()
|
||||
expect(instance.getSnapshot().byTab).toEqual({})
|
||||
expect(instance.getSnapshot().byTab).toEqual({ kept: { page: 3 } })
|
||||
kept.abort()
|
||||
instance.actions.page('ended' as TabId, 3)
|
||||
face.retainTab('ended' as TabId, controller.signal)
|
||||
expect(instance.getSnapshot().byTab).toEqual({})
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Shared image zoom preferences remain isolated by tab identity. */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
|
||||
import { createZoomStore, DEFAULT_ZOOM } from '../src/client/zoom/store.ts'
|
||||
|
||||
describe('document zoom store', () => {
|
||||
it('retains fixed and fit-width preferences and forgets only the closed tab', () => {
|
||||
const instance = createZoomStore().create()
|
||||
const one = 'one' as TabId
|
||||
const two = 'two' as TabId
|
||||
instance.actions.zoom(one, { kind: 'fixed', scale: 1.5 })
|
||||
instance.actions.zoom(two, DEFAULT_ZOOM)
|
||||
expect(instance.getSnapshot().byTab).toEqual({
|
||||
one: { kind: 'fixed', scale: 1.5 }, two: { kind: 'fit-width' },
|
||||
})
|
||||
instance.actions.forget(one)
|
||||
expect(instance.getSnapshot().byTab).toEqual({ two: { kind: 'fit-width' } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Shared zoom fitting follows pane size until the reader selects a fixed scale. */
|
||||
import { useState, type CSSProperties } from 'react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { fitWidthZoom, ZoomViewport, zoomSurfaceClass } from '../src/client/zoom/ZoomViewport.tsx'
|
||||
import { FIT_WIDTH, type ZoomPreference } from '../src/client/zoom/types.ts'
|
||||
|
||||
let width = 500
|
||||
let resize: ResizeObserverCallback | undefined
|
||||
let elementFromPointDescriptor: PropertyDescriptor | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
width = 500
|
||||
elementFromPointDescriptor = Object.getOwnPropertyDescriptor(document, 'elementFromPoint')
|
||||
Object.defineProperty(document, 'elementFromPoint', { configurable: true, value: () => null })
|
||||
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => width)
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
constructor(callback: ResizeObserverCallback) { resize = callback }
|
||||
observe(): void {}
|
||||
disconnect(): void {}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
if (elementFromPointDescriptor === undefined) Reflect.deleteProperty(document, 'elementFromPoint')
|
||||
else Object.defineProperty(document, 'elementFromPoint', elementFromPointDescriptor)
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('fits without upscaling, follows resize, and leaves fixed zoom unchanged', () => {
|
||||
function View() {
|
||||
const [preference, setPreference] = useState<ZoomPreference>(FIT_WIDTH)
|
||||
return <ZoomViewport preference={preference} intrinsicWidth={1000} horizontalInset={20}
|
||||
labels={{ controls: 'Zoom controls', menu: 'Choose zoom', out: 'Zoom out', into: 'Zoom in',
|
||||
fitWidth: 'Fit width', value: percent => `${String(percent)}%` }} signal={new AbortController().signal}
|
||||
scrollportRef={() => {}} onPreference={setPreference}>
|
||||
<div className={zoomSurfaceClass} style={{ '--document-zoom-width': '1000px' } as CSSProperties} />
|
||||
</ZoomViewport>
|
||||
}
|
||||
const view = render(<View />)
|
||||
expect(screen.getByRole('button', { name: 'Choose zoom' }).textContent).toContain('48%')
|
||||
expect(view.container.querySelector('[data-document-zoom-mode]')?.getAttribute('data-document-zoom-mode')).toBe('fit-width')
|
||||
width = 320
|
||||
act(() => { resize?.([], {} as ResizeObserver) })
|
||||
expect(screen.getByRole('button', { name: 'Choose zoom' }).textContent).toContain('30%')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose zoom' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '100%' }))
|
||||
width = 220
|
||||
act(() => { resize?.([], {} as ResizeObserver) })
|
||||
expect(screen.getByRole('button', { name: 'Choose zoom' }).textContent).toContain('100%')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose zoom' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Fit width' }))
|
||||
expect(screen.getByRole('button', { name: 'Choose zoom' }).textContent).toContain('20%')
|
||||
fireEvent.wheel(view.container.querySelector('[data-document-zoom-scrollport]') as HTMLDivElement,
|
||||
{ ctrlKey: true, deltaY: 40 })
|
||||
expect(screen.getByRole('button', { name: 'Choose zoom' }).textContent).toContain('25%')
|
||||
})
|
||||
|
||||
it('resolves missing and small content to 100 percent', () => {
|
||||
expect(fitWidthZoom(200, undefined, 24)).toBe(1)
|
||||
expect(fitWidthZoom(200, 0, 24)).toBe(1)
|
||||
expect(fitWidthZoom(200, 100, 24)).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps a centred surface point under the pointer while crossing into overflow', () => {
|
||||
const onPreference = vi.fn()
|
||||
const view = render(<ZoomViewport preference={{ kind: 'fixed', scale: 0.5 }} intrinsicWidth={400}
|
||||
labels={{ controls: 'Zoom controls', menu: 'Choose zoom', out: 'Zoom out', into: 'Zoom in',
|
||||
fitWidth: 'Fit width', value: percent => `${String(percent)}%` }} signal={new AbortController().signal}
|
||||
scrollportRef={() => {}} onPreference={onPreference}>
|
||||
<div className={zoomSurfaceClass} data-document-zoom-surface
|
||||
style={{ '--document-zoom-width': '400px' } as CSSProperties} />
|
||||
</ZoomViewport>)
|
||||
const frame = view.container.querySelector('[data-document-zoom-frame]') as HTMLElement
|
||||
const scrollport = view.container.querySelector('[data-document-zoom-scrollport]') as HTMLDivElement
|
||||
const surface = view.container.querySelector('[data-document-zoom-surface]') as HTMLDivElement
|
||||
Object.defineProperties(scrollport, {
|
||||
clientWidth: { configurable: true, value: 600 },
|
||||
clientHeight: { configurable: true, value: 400 },
|
||||
})
|
||||
scrollport.getBoundingClientRect = () => ({ x: 0, y: 0, left: 0, top: 0, right: 600, bottom: 400,
|
||||
width: 600, height: 400, toJSON: () => ({}) })
|
||||
surface.getBoundingClientRect = () => {
|
||||
const scale = Number(frame.style.getPropertyValue('--document-zoom'))
|
||||
const surfaceWidth = 400 * scale
|
||||
const surfaceHeight = 200 * scale
|
||||
const left = (surfaceWidth < 600 ? (600 - surfaceWidth) / 2 : 0) - scrollport.scrollLeft
|
||||
const top = (400 - surfaceHeight) / 2 - scrollport.scrollTop
|
||||
return { x: left, y: top, left, top, right: left + surfaceWidth, bottom: top + surfaceHeight,
|
||||
width: surfaceWidth, height: surfaceHeight, toJSON: () => ({}) }
|
||||
}
|
||||
const elementFromPoint = Object.getOwnPropertyDescriptor(document, 'elementFromPoint')
|
||||
Object.defineProperty(document, 'elementFromPoint', { configurable: true, value: () => surface })
|
||||
try {
|
||||
for (let index = 0; index < 3; index++) {
|
||||
scrollport.dispatchEvent(new WheelEvent('wheel', { bubbles: true, cancelable: true, ctrlKey: true,
|
||||
deltaY: -40, clientX: 300, clientY: 200 }))
|
||||
}
|
||||
const scale = 0.5 * Math.exp(1.2)
|
||||
expect(Number(frame.style.getPropertyValue('--document-zoom'))).toBeCloseTo(scale)
|
||||
expect(scrollport.scrollLeft).toBeCloseTo((400 * scale - 600) / 2)
|
||||
expect(scrollport.scrollTop).toBeCloseTo(0)
|
||||
expect(onPreference).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
if (elementFromPoint === undefined) Reflect.deleteProperty(document, 'elementFromPoint')
|
||||
else Object.defineProperty(document, 'elementFromPoint', elementFromPoint)
|
||||
}
|
||||
})
|
||||
|
||||
it('reveals from the bottom zone and delays hiding while controls stay active', async () => {
|
||||
vi.useFakeTimers()
|
||||
const view = render(<ZoomViewport preference={FIT_WIDTH} intrinsicWidth={400}
|
||||
labels={{ controls: 'Zoom controls', menu: 'Choose zoom', out: 'Zoom out', into: 'Zoom in',
|
||||
fitWidth: 'Fit width', value: percent => `${String(percent)}%` }} signal={new AbortController().signal}
|
||||
scrollportRef={() => {}} onPreference={() => {}}>
|
||||
<div className={zoomSurfaceClass} style={{ '--document-zoom-width': '400px' } as CSSProperties} />
|
||||
</ZoomViewport>)
|
||||
const frame = view.container.querySelector('[data-document-zoom-frame]') as HTMLElement
|
||||
const controls = view.container.querySelector('[data-document-zoom-controls]') as HTMLElement
|
||||
frame.getBoundingClientRect = () => ({ x: 0, y: 0, left: 0, top: 0, right: 500, bottom: 400,
|
||||
width: 500, height: 400, toJSON: () => ({}) })
|
||||
expect(controls.hasAttribute('data-document-zoom-visible')).toBe(false)
|
||||
fireEvent.pointerMove(frame, { pointerType: 'mouse', clientY: 350 })
|
||||
expect(controls.getAttribute('data-document-zoom-visible')).toBe('true')
|
||||
fireEvent.pointerMove(frame, { pointerType: 'mouse', clientY: 100 })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(419) })
|
||||
expect(controls.getAttribute('data-document-zoom-visible')).toBe('true')
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(1) })
|
||||
expect(controls.hasAttribute('data-document-zoom-visible')).toBe(false)
|
||||
|
||||
fireEvent.pointerMove(frame, { pointerType: 'mouse', clientY: 350 })
|
||||
fireEvent.pointerEnter(controls)
|
||||
fireEvent.pointerMove(frame, { pointerType: 'mouse', clientY: 100 })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(500) })
|
||||
expect(controls.getAttribute('data-document-zoom-visible')).toBe('true')
|
||||
fireEvent.pointerLeave(controls)
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(420) })
|
||||
expect(controls.hasAttribute('data-document-zoom-visible')).toBe(false)
|
||||
|
||||
const menuButton = screen.getByRole('button', { name: 'Choose zoom' })
|
||||
fireEvent.focus(menuButton)
|
||||
expect(controls.getAttribute('data-document-zoom-visible')).toBe('true')
|
||||
fireEvent.pointerLeave(frame)
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(500) })
|
||||
expect(controls.getAttribute('data-document-zoom-visible')).toBe('true')
|
||||
fireEvent.blur(menuButton)
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(420) })
|
||||
expect(controls.hasAttribute('data-document-zoom-visible')).toBe(false)
|
||||
|
||||
fireEvent.pointerMove(frame, { pointerType: 'mouse', clientY: 350 })
|
||||
fireEvent.click(menuButton)
|
||||
fireEvent.pointerLeave(frame)
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(500) })
|
||||
expect(controls.getAttribute('data-document-zoom-visible')).toBe('true')
|
||||
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(420) })
|
||||
expect(controls.hasAttribute('data-document-zoom-visible')).toBe(false)
|
||||
view.unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
|
||||
README.md: 3fd9308f0f47a561a08f831f7d923aad2863a25b
|
||||
README.zh.md: 97bcac6456bb68606a4747984c24f5f618fa9bb4
|
||||
README.md: 7dcff13bc1767051c301174240979873444d4291
|
||||
README.zh.md: 1b3e83c71f700976799ff5b93b6fe4c60f896583
|
||||
@@ -49,7 +49,7 @@ Collapsed search is one header action beside the view and add actions: activatin
|
||||
|
||||
The Session row's Rename action opens a dialog prefilled with the row's display title; confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. Double-clicking a title also opens Rename; for an unarchived Session, the preceding clicks open its conversation. Rename uses a temporary `workspaceOperation` reference and awaits its initial history opening. The row's Fork action forks at the source's last completed turn and increments the inherited persisted title through Session Controller without retaining the child, opening its history, or changing selection. Workspace Delete opens a confirmation that states the retention boundary; success removes the group while its Sessions remain under Ungrouped. Pin, Rename, Fork, and Archive are themselves entries of the `sidebar.workspaces.session.menu.item` list (pin and archive also of `sidebar.workspaces.session.row.action`), so a client plugin's action takes whatever position its `order` gives it.
|
||||
|
||||
Archive commits without a confirmation dialog and retains the Session's account position. View options control visibility: the default hides archived Sessions, Show archived includes them, and Archived only hides ordinary Sessions. Visible archived rows are grayed and carry an accessible explanation that they cannot be opened until restored; Rename, Fork, and Unarchive remain available. A successful archive shows a notice with an undo action and a "show archived" action that switches the filter directly. Unarchive removes the archive mark without restoring a pin or changing the saved position.
|
||||
Archive commits without a confirmation dialog for a quiet Session and retains the Session's account position. A Session with running work is the one case that asks first: the Host refuses the plain archive and names that work, and the sidebar opens a stop-and-archive dialog listing it by family — the turn in progress, running subagents, background jobs, scheduled reminders, each with its labels — with the restore path stated; confirming asks the Host to stop the work the way the stop button does and archives once the archive set is durable, while the stops settle in the background, and cancelling leaves the Session running and visible. View options control visibility: the default hides archived Sessions, Show archived includes them, and Archived only hides ordinary Sessions. Visible archived rows are grayed and carry an accessible explanation that they cannot be opened until restored; Rename, Fork, and Unarchive remain available. A successful archive shows a notice with an undo action and a "show archived" action that switches the filter directly; a stop-and-archive shows the same notice under its own wording, and undo restores the Session without resuming the stopped work. Unarchive removes the archive mark without restoring a pin or changing the saved position.
|
||||
|
||||
A Session title wider than its row is clipped with an ellipsis at rest. Hovering the row scrolls the title to its far edge — the incremented title of a fork, for example — and reveals it without the ellipsis; leaving the row returns the title to its start.
|
||||
|
||||
@@ -91,7 +91,7 @@ Each registration declares a **directory-flow child hole** (`single` kind: `conv
|
||||
|
||||
The Session row's "..." menu and its hover buttons are two `list` slots declared by the WorkspaceBrowser entry: `sidebar.workspaces.session.menu.item` and `sidebar.workspaces.session.row.action`. Every menu row and every hover button is an entry, this package's own actions included: `apply` registers `pin` (menu 100, button 200), `rename` (200), `fork` (300), and `archive` (menu 400, button 100) the way a client plugin registers its actions, so a plugin action lands wherever its `order` says, and reusing a shipped id at another `priority` shadows that action.
|
||||
|
||||
An entry receives only the row identity (`sessionId`, `displayTitle`) and owns everything else. It reads the Host state it cares about through its own injected hooks (the pin and archive sets, as Sets derived once per Workspace snapshot), decides its own visibility (pin renders nothing on an archived row, because the Host keeps the two sets exclusive), carries its whole behavior in its registration's `inject` face (a pin also fronts the Session in its saved orders; an archive raises the notice), and raises its own surfaces: the rename dialog and the row-action notice are this package's `shell.overlay` entries, fed by the requests the actions inject. A menu entry renders one `role="menuitem"` button (this package's rows use `MenuItemButton` from ui-primitives, which adds the host styling and `separatorBefore` for a group start; the hairline comes and goes with the row) and dismisses the menu through the slot-level `useMenuOpenState` hook, the menu's own open state bound from the row's render occurrence; a hover button entry renders one icon button, and the strip keeps its clicks from opening the row. The browser passes no action callbacks to its rows; its remaining verbs are the search results' restore button and the title double-click, which raises the same rename request.
|
||||
An entry receives only the row identity (`sessionId`, `displayTitle`) and owns everything else. It reads the Host state it cares about through its own injected hooks (the pin and archive sets, as Sets derived once per Workspace snapshot), decides its own visibility (pin renders nothing on an archived row, because the Host keeps the two sets exclusive), carries its whole behavior in its registration's `inject` face (a pin also fronts the Session in its saved orders; an archive raises the notice), and raises its own surfaces: the rename dialog, the stop-and-archive dialog, and the row-action notice are this package's `shell.overlay` entries, fed by the requests the actions inject. A menu entry renders one `role="menuitem"` button (this package's rows use `MenuItemButton` from ui-primitives, which adds the host styling and `separatorBefore` for a group start; the hairline comes and goes with the row) and dismisses the menu through the slot-level `useMenuOpenState` hook, the menu's own open state bound from the row's render occurrence; a hover button entry renders one icon button, and the strip keeps its clicks from opening the row. The browser passes no action callbacks to its rows; its remaining verbs are the search results' restore button and the title double-click, which raises the same rename request.
|
||||
|
||||
#### Packaged client plugin
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ kind: "package-reference"
|
||||
|
||||
Session 行内的 Rename 操作打开一个以该行显示标题预填的对话框;确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。双击标题也会打开 Rename;对于未归档 Session,先发生的点击会打开其对话。Rename 使用临时 `workspaceOperation` reference,并等待首次历史打开。行内 Fork 在源会话最后一个已完成轮次处 fork,通过 Session Controller 递增继承的持久化标题,不 retain 子会话、不打开其历史,也不改变选择。Workspace 行内的 Delete 操作会打开确认框,说明保留边界;成功后该分组被移除,其 Session 则留在 Ungrouped 下。Pin、Rename、Fork、Archive 本身就是 `sidebar.workspaces.session.menu.item` 列表的条目(pin 与 archive 同时也是 `sidebar.workspaces.session.row.action` 的条目),因此客户端插件的 action 由其 `order` 决定落在哪个位置。
|
||||
|
||||
Archive 不经确认对话框直接提交,并保留 Session 的记账位置。视图选项控制显隐:默认隐藏已归档 Session,显示已归档会将其纳入列表,仅显示已归档则隐藏普通 Session。可见的归档行置灰,并提供无障碍说明,告知取消归档后才能打开;Rename、Fork 与取消归档仍然可用。归档成功后的提示提供"撤销"和"筛选已归档会话"两个动作,后者直接把筛选切到显示已归档。取消归档移除归档标记,但不恢复置顶,也不改变保存的位置。
|
||||
对静止的 Session,Archive 不经确认对话框直接提交,并保留 Session 的记账位置。仍有工作在跑的 Session 是唯一会先询问的情形:Host 拒绝普通归档并列出这些工作,侧栏随即打开"停止并归档"对话框,按族列出——进行中的回合、运行中的子代理、后台任务、定时提醒,各带名称——并写明恢复路径;确认后请 Host 按停止按钮同样的方式停止这些工作,归档集合持久化后即完成归档,停止在后台收敛;取消则让 Session 继续运行并保持可见。视图选项控制显隐:默认隐藏已归档 Session,显示已归档会将其纳入列表,仅显示已归档则隐藏普通 Session。可见的归档行置灰,并提供无障碍说明,告知取消归档后才能打开;Rename、Fork 与取消归档仍然可用。归档成功后的提示提供"撤销"和"筛选已归档会话"两个动作,后者直接把筛选切到显示已归档;停止并归档显示同样的提示但措辞不同,撤销只恢复 Session,不会让被停止的工作继续。取消归档移除归档标记,但不恢复置顶,也不改变保存的位置。
|
||||
|
||||
标题宽于所在行时,静止状态以省略号裁切。把指针停在行上,标题会滚动到远端——例如 fork 递增后的标题——并在揭示时不显示省略号;指针离开后标题回到开头。
|
||||
|
||||
@@ -91,7 +91,7 @@ Client 在启动时按其语言选择初始目录名和标题:中文使用 `
|
||||
|
||||
Session 行的 "..." 菜单和行尾悬停按钮是 WorkspaceBrowser 注册项声明的两个 `list` slot:`sidebar.workspaces.session.menu.item` 与 `sidebar.workspaces.session.row.action`。每一个菜单行、每一个悬停按钮都是条目,本包自己的 action 也不例外:`apply` 以客户端插件注册自己 action 的同一方式注册 `pin`(菜单 100、按钮 200)、`rename`(200)、`fork`(300)、`archive`(菜单 400、按钮 100),因此插件 action 落在其 `order` 所指的位置,以另一个 `priority` 复用内置 id 则遮蔽该 action。
|
||||
|
||||
条目只接收行身份(`sessionId`、`displayTitle`),其余一切自己负责:用自己注入的 hook 读自己关心的 Host 状态(置顶与归档集合,以每次 Workspace 快照只派生一次的 Set 形式),自己决定是否显示(Host 规定归档与置顶互斥,所以 pin 在归档行上不渲染),整套行为放在注册项自己的 `inject` face 里(置顶成功后顺带把会话推到保存顺序最前,归档成功后发提示),浮层也自己带——重命名对话框和行 action 的提示是本包注册在 `shell.overlay` 的条目,由 action 注入的请求驱动。菜单条目渲染一个 `role="menuitem"` 的按钮(本包自己的行用 ui-primitives 的 `MenuItemButton`,它带宿主样式,开启新分组的行加 `separatorBefore`,分隔线随行一起出现和消失),并通过 slot 级 `useMenuOpenState` hook(菜单自身的打开状态,从该行的渲染出现处绑定)关闭菜单;悬停按钮条目渲染一个图标按钮,按钮条会拦住点击、不让它打开该行。browser 不再向行传任何 action 回调,它剩下的动作只有搜索结果里的恢复按钮和标题双击,后者发出的是同一个重命名请求。
|
||||
条目只接收行身份(`sessionId`、`displayTitle`),其余一切自己负责:用自己注入的 hook 读自己关心的 Host 状态(置顶与归档集合,以每次 Workspace 快照只派生一次的 Set 形式),自己决定是否显示(Host 规定归档与置顶互斥,所以 pin 在归档行上不渲染),整套行为放在注册项自己的 `inject` face 里(置顶成功后顺带把会话推到保存顺序最前,归档成功后发提示),浮层也自己带——重命名对话框、停止并归档对话框和行 action 的提示是本包注册在 `shell.overlay` 的条目,由 action 注入的请求驱动。菜单条目渲染一个 `role="menuitem"` 的按钮(本包自己的行用 ui-primitives 的 `MenuItemButton`,它带宿主样式,开启新分组的行加 `separatorBefore`,分隔线随行一起出现和消失),并通过 slot 级 `useMenuOpenState` hook(菜单自身的打开状态,从该行的渲染出现处绑定)关闭菜单;悬停按钮条目渲染一个图标按钮,按钮条会拦住点击、不让它打开该行。browser 不再向行传任何 action 回调,它剩下的动作只有搜索结果里的恢复按钮和标题双击,后者发出的是同一个重命名请求。
|
||||
|
||||
#### 打包客户端插件
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
@@ -66,6 +67,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-jobs": "workspace:^",
|
||||
"@deepseek-ai/dsh-schedule": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
|
||||
@@ -42,7 +42,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { SessionSearchResultItem } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client'
|
||||
import type { SessionActivity, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { createWorkspaceViewStore } from '../stores.ts'
|
||||
|
||||
@@ -241,6 +241,7 @@ export type SessionRowActionProps<Injected extends object = object> =
|
||||
/** One transient Workspace notice rendered by the overlay toast entry. */
|
||||
export type RowToast =
|
||||
| { kind: 'archived'; sessionId: SessionId }
|
||||
| { kind: 'stoppedAndArchived'; sessionId: SessionId }
|
||||
| { kind: 'pinFailed' }
|
||||
| { kind: 'unpinFailed' }
|
||||
| { kind: 'archivedNotOpenable' }
|
||||
@@ -276,8 +277,9 @@ export interface PinSessionInjected {
|
||||
|
||||
/**
|
||||
* Archive action share (menu row and hover button). The callbacks carry the
|
||||
* whole behavior: the Host call, the notice a success raises, and the
|
||||
* diagnostics for a rejection.
|
||||
* whole behavior: the Host call, the notice a success raises, the
|
||||
* stop-and-archive confirmation a Host refusal for running work raises, and
|
||||
* the diagnostics for any other rejection.
|
||||
*/
|
||||
export interface ArchiveSessionInjected {
|
||||
hooks: {
|
||||
@@ -288,12 +290,46 @@ export interface ArchiveSessionInjected {
|
||||
* Archive a Session into the registry-global set: the row keeps its
|
||||
* account position and shows per the archived filter; archiving the
|
||||
* current session clears the selection into the New Session view state.
|
||||
* A Session with running work is not archived by this call: the Host's
|
||||
* refusal opens the stop-and-archive confirmation instead.
|
||||
*/
|
||||
archiveSession: (sessionId: SessionId) => void
|
||||
/** Remove a Session from the registry-global archived set. */
|
||||
unarchiveSession: (sessionId: SessionId) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* A stop-and-archive confirmation the archive action asked for: the Host
|
||||
* refused the plain archive because this work still runs.
|
||||
*/
|
||||
export interface SessionArchiveConfirmRequest {
|
||||
/** Session to stop and archive. */
|
||||
sessionId: SessionId
|
||||
/** The row's display title, named in the dialog. */
|
||||
displayTitle: string
|
||||
/** What the Host reported running, in family order. */
|
||||
activity: readonly SessionActivity[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop-and-archive dialog share: the pending confirmation, its settlement,
|
||||
* and the archive hop that asks the Host to stop the work first.
|
||||
*/
|
||||
export interface SessionArchiveConfirmInjected {
|
||||
hooks: {
|
||||
/** The confirmation asked for, until the dialog consumes or cancels it. */
|
||||
archiveRequest: HostObservable<SessionArchiveConfirmRequest | null>
|
||||
}
|
||||
/** Consume or cancel the pending confirmation. */
|
||||
settleSessionArchive: () => void
|
||||
/**
|
||||
* Archive a Session after the Host stops its running work; resolves once
|
||||
* the archive set is durable (the stops settle in the background) and
|
||||
* raises the stopped-and-archived notice.
|
||||
*/
|
||||
stopAndArchiveSession: (sessionId: SessionId) => Promise<void>
|
||||
}
|
||||
|
||||
/** Fork action share. */
|
||||
export interface ForkSessionInjected {
|
||||
/** Fork a Session at its last completed turn; the child arrives through the Host list. */
|
||||
@@ -347,6 +383,13 @@ export type SessionRenameDialogProps =
|
||||
& Omit<SessionRenameDialogInjected, 'hooks'>
|
||||
& PropsHooks<SessionRenameDialogInjected['hooks']>
|
||||
|
||||
/** Props of the stop-and-archive dialog entry in `shell.overlay`. */
|
||||
export type SessionArchiveConfirmProps =
|
||||
PropsRuntime<'shell.overlay'>
|
||||
& PropsLocale<'workspace'>
|
||||
& Omit<SessionArchiveConfirmInjected, 'hooks'>
|
||||
& PropsHooks<SessionArchiveConfirmInjected['hooks']>
|
||||
|
||||
/** Props of the row toast entry in `shell.overlay`. */
|
||||
export type RowToastProps =
|
||||
PropsRuntime<'shell.overlay'>
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { IWorkspaces, WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
|
||||
import type {
|
||||
IWorkspaces, SessionActivity, WorkspaceArchiveError, WorkspaceSnapshot,
|
||||
} from '@deepseek-ai/dsh-api-workspace-controller/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { HostObservable, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
@@ -31,13 +33,14 @@ import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
|
||||
import {
|
||||
type ArchiveSessionInjected, type ForkSessionInjected, menuOpenStateFactory, type PinSessionInjected,
|
||||
type SessionArchiveConfirmInjected, type SessionArchiveConfirmRequest,
|
||||
type RenameSessionInjected, type RowToast, type RowToastInjected, type RowToastState, type SessionRenameDialogInjected,
|
||||
type SessionRenameTarget, type WorkspaceBrowserInjected, type WorkspacePickerInjected,
|
||||
} from './contract/slots.ts'
|
||||
import { UiWorkspaceService } from './navigation.ts'
|
||||
import { createWorkspaceViewStore } from './stores.ts'
|
||||
import { WorkspaceBrowser } from './rows/WorkspaceBrowser.tsx'
|
||||
import { ArchiveSessionMenuItem, ArchiveSessionRowButton } from './session-actions/ArchiveSession.tsx'
|
||||
import { ArchiveSessionMenuItem, ArchiveSessionRowButton, SessionArchiveConfirmDialog } from './session-actions/ArchiveSession.tsx'
|
||||
import { derive } from './session-actions/derived.ts'
|
||||
import { ForkSessionMenuItem } from './session-actions/ForkSession.tsx'
|
||||
import { PinSessionMenuItem, PinSessionRowButton } from './session-actions/PinSession.tsx'
|
||||
@@ -141,6 +144,7 @@ export function apply(ctx: Context): void {
|
||||
// writes through its own injected callback and the surface reads through
|
||||
// its bound hook.
|
||||
const renameRequest = createSnapshotStore<SessionRenameTarget | null>(null)
|
||||
const archiveRequest = createSnapshotStore<SessionArchiveConfirmRequest | null>(null)
|
||||
const requestSessionRename = (sessionId: SessionId, currentTitle: string): void => {
|
||||
renameRequest.set({ sessionId, currentTitle })
|
||||
}
|
||||
@@ -170,17 +174,33 @@ export function apply(ctx: Context): void {
|
||||
})
|
||||
const archiveInjected = (): ArchiveSessionInjected => ({
|
||||
hooks: { archived: archivedSet },
|
||||
// Archive preserves the log and the account position, so it needs no
|
||||
// confirmation; the notice offers undo and the archived filter.
|
||||
// Archive preserves the log and the account position, so a quiet Session
|
||||
// needs no confirmation; the notice offers undo and the archived filter.
|
||||
// The Host's refusal for running work is the one case that asks first:
|
||||
// the confirmation names that work and offers to stop it.
|
||||
archiveSession: (sessionId) => {
|
||||
uiWorkspace.archiveSession(sessionId).then(() => {
|
||||
notify({ kind: 'archived', sessionId })
|
||||
}).catch((reason: unknown) => {
|
||||
console.warn('session archive rejected:', reason)
|
||||
const activity = activeSessionRefusal(reason)
|
||||
if (activity === undefined) {
|
||||
console.warn('session archive rejected:', reason)
|
||||
return
|
||||
}
|
||||
const displayTitle = sessions.list.getSnapshot().byId[sessionId]?.displayTitle ?? sessionId
|
||||
archiveRequest.set({ sessionId, displayTitle, activity })
|
||||
})
|
||||
},
|
||||
unarchiveSession,
|
||||
})
|
||||
const archiveConfirmInjected = (): SessionArchiveConfirmInjected => ({
|
||||
hooks: { archiveRequest },
|
||||
settleSessionArchive: () => { archiveRequest.set(null) },
|
||||
stopAndArchiveSession: async (sessionId) => {
|
||||
await uiWorkspace.archiveSession(sessionId, { stopActivity: true })
|
||||
notify({ kind: 'stoppedAndArchived', sessionId })
|
||||
},
|
||||
})
|
||||
const forkInjected = (): ForkSessionInjected => ({
|
||||
forkSession: (sessionId) => {
|
||||
uiWorkspace.forkSession(sessionId).catch(() => {
|
||||
@@ -263,6 +283,9 @@ export function apply(ctx: Context): void {
|
||||
yield ctx.slots.register({
|
||||
name: 'shell.overlay', id: 'workspace.session-rename', locale: NS, inject: renameDialogInjected,
|
||||
}, SessionRenameDialog)
|
||||
yield ctx.slots.register({
|
||||
name: 'shell.overlay', id: 'workspace.session-archive', locale: NS, inject: archiveConfirmInjected,
|
||||
}, SessionArchiveConfirmDialog)
|
||||
yield ctx.slots.register({
|
||||
name: 'shell.overlay', id: 'workspace.row-toast', locale: NS, inject: rowToastInjected,
|
||||
}, RowActionToast)
|
||||
@@ -277,3 +300,14 @@ export function apply(ctx: Context): void {
|
||||
WorkspacePicker,
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
* The activity a Host `workspace/session-active` refusal reported, or nothing
|
||||
* for any other failure. The class identity check goes by name: client plugin
|
||||
* bundles do not share error-class identity.
|
||||
*/
|
||||
function activeSessionRefusal(reason: unknown): readonly SessionActivity[] | undefined {
|
||||
if (!(reason instanceof Error) || reason.name !== 'WorkspaceArchiveError') return undefined
|
||||
const { rpcError } = reason as WorkspaceArchiveError
|
||||
return rpcError.code === 'workspace/session-active' ? rpcError.details.activity : undefined
|
||||
}
|
||||
@@ -57,6 +57,22 @@ export const zh = {
|
||||
'row.pinned': '已置顶',
|
||||
'toast.archivedNotOpenable': '已归档对话暂时无法查看,请取消归档后查看',
|
||||
'toast.archived': '会话已归档,可',
|
||||
'toast.stoppedAndArchived': '已停止并归档,可',
|
||||
'archive.confirm.title': '停止并归档此会话?',
|
||||
'archive.confirm.desc': '“{title}”仍有正在进行的工作。归档会先停止这些工作;之后可在侧栏“显示已归档”中恢复会话,被停止的工作不会自动继续。',
|
||||
'archive.confirm.activity': '将被停止的工作',
|
||||
'archive.confirm.turn': '进行中的回合',
|
||||
'archive.confirm.subagents.one': '{n} 个运行中的子代理:{names}',
|
||||
'archive.confirm.subagents.other': '{n} 个运行中的子代理:{names}',
|
||||
'archive.confirm.jobs.one': '{n} 个后台任务:{names}',
|
||||
'archive.confirm.jobs.other': '{n} 个后台任务:{names}',
|
||||
'archive.confirm.schedules.one': '{n} 条定时提醒:{names}',
|
||||
'archive.confirm.schedules.other': '{n} 条定时提醒:{names}',
|
||||
'archive.confirm.other.one': '{n} 项其他工作({kind})',
|
||||
'archive.confirm.other.other': '{n} 项其他工作({kind})',
|
||||
'archive.confirm.listSeparator': '、',
|
||||
'archive.confirm.action': '停止并归档',
|
||||
'archive.confirm.pending': '正在停止并归档…',
|
||||
'toast.archivedUndo': '撤销',
|
||||
'toast.archivedOr': '或',
|
||||
'toast.archivedFilter': '筛选已归档会话',
|
||||
@@ -153,6 +169,22 @@ export const en = {
|
||||
'row.pinned': 'Pinned',
|
||||
'toast.archivedNotOpenable': 'Archived sessions cannot be opened. Unarchive it to view.',
|
||||
'toast.archived': 'Session archived. You can ',
|
||||
'toast.stoppedAndArchived': 'Session stopped and archived. You can ',
|
||||
'archive.confirm.title': 'Stop and archive this session?',
|
||||
'archive.confirm.desc': '“{title}” still has work in progress. Archiving stops it first; you can restore the session later from “Show archived” in the sidebar, and the stopped work will not resume on its own.',
|
||||
'archive.confirm.activity': 'Work that will be stopped',
|
||||
'archive.confirm.turn': 'The turn in progress',
|
||||
'archive.confirm.subagents.one': '{n} running subagent: {names}',
|
||||
'archive.confirm.subagents.other': '{n} running subagents: {names}',
|
||||
'archive.confirm.jobs.one': '{n} background job: {names}',
|
||||
'archive.confirm.jobs.other': '{n} background jobs: {names}',
|
||||
'archive.confirm.schedules.one': '{n} scheduled reminder: {names}',
|
||||
'archive.confirm.schedules.other': '{n} scheduled reminders: {names}',
|
||||
'archive.confirm.other.one': '{n} other item of work ({kind})',
|
||||
'archive.confirm.other.other': '{n} other items of work ({kind})',
|
||||
'archive.confirm.listSeparator': ', ',
|
||||
'archive.confirm.action': 'Stop and archive',
|
||||
'archive.confirm.pending': 'Stopping and archiving…',
|
||||
'toast.archivedUndo': 'undo',
|
||||
'toast.archivedOr': ' or ',
|
||||
'toast.archivedFilter': 'filter archived sessions',
|
||||
|
||||
@@ -64,8 +64,9 @@ export interface UiWorkspace {
|
||||
/**
|
||||
* Archive a Session and clear it when it is the current selection.
|
||||
* @param sessionId - Session to archive.
|
||||
* @param options - `stopActivity` asks the Host to stop the Session's running work instead of refusing.
|
||||
*/
|
||||
archiveSession(sessionId: SessionId): Promise<void>
|
||||
archiveSession(sessionId: SessionId, options?: { readonly stopActivity?: boolean }): Promise<void>
|
||||
/**
|
||||
* Unarchive a Session, restoring it to its recorded Workspace position.
|
||||
* @param sessionId - Session to unarchive.
|
||||
@@ -239,8 +240,8 @@ class UiWorkspaceService extends Service implements UiWorkspace {
|
||||
)
|
||||
}
|
||||
|
||||
async archiveSession(sessionId: SessionId): Promise<void> {
|
||||
await this.workspaces.archiveSession(sessionId)
|
||||
async archiveSession(sessionId: SessionId, options: { readonly stopActivity?: boolean } = {}): Promise<void> {
|
||||
await this.workspaces.archiveSession(sessionId, options)
|
||||
if (this.mainReference?.sessionId === sessionId) this.clearMain()
|
||||
}
|
||||
|
||||
|
||||
@@ -556,6 +556,19 @@
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* The stop-and-archive dialog's list of work that will be stopped. */
|
||||
.archiveActivity {
|
||||
margin: 0 0 8px;
|
||||
padding-left: 18px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.archiveActivity li {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wide {
|
||||
animation: none;
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
/**
|
||||
* The archive action: a `sidebar.workspaces.session.menu.item` row and a
|
||||
* `sidebar.workspaces.session.row.action` button over one injected behavior.
|
||||
* The same entries restore an archived row; the notice a successful archive
|
||||
* raises and the diagnostics for Host rejections live in the injected
|
||||
* callbacks, not here.
|
||||
* `sidebar.workspaces.session.row.action` button over one injected behavior,
|
||||
* plus the `shell.overlay` dialog that confirms stopping a Session's running
|
||||
* work before archiving it. The same entries restore an archived row; the
|
||||
* notice a successful archive raises and the diagnostics for Host rejections
|
||||
* live in the injected callbacks, not here.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import type { SessionActivity } from '@deepseek-ai/dsh-api-workspace-controller/client'
|
||||
// Type-only: the family keys each provider merges; a key this program did not compile takes the generic line.
|
||||
import type {} from '@deepseek-ai/dsh-agent/types'
|
||||
import type {} from '@deepseek-ai/dsh-jobs/view'
|
||||
import type {} from '@deepseek-ai/dsh-schedule/client'
|
||||
import type {} from '@deepseek-ai/dsh-subagent/client'
|
||||
import {
|
||||
IconArchiveOutlineRegular, IconUnarchiveOutlineRegular, MenuItemButton, Tooltip,
|
||||
Button, IconArchiveOutlineRegular, IconUnarchiveOutlineRegular, MenuItemButton, Modal, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ArchiveSessionInjected, SessionMenuItemProps, SessionRowActionProps } from '../contract/slots.ts'
|
||||
import type {
|
||||
ArchiveSessionInjected, SessionArchiveConfirmInjected, SessionArchiveConfirmProps, SessionArchiveConfirmRequest,
|
||||
SessionMenuItemProps, SessionRowActionProps,
|
||||
} from '../contract/slots.ts'
|
||||
import css from '../rows/Rows.module.css'
|
||||
import browserCss from '../rows/WorkspaceBrowser.module.css'
|
||||
|
||||
/**
|
||||
* Menu row (order 400): archive, or restore an archived row.
|
||||
@@ -56,3 +68,102 @@ export function ArchiveSessionRowButton({
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `shell.overlay` entry: nothing while no confirmation is pending,
|
||||
* otherwise one dialog per request (keyed by the Session). Confirming asks
|
||||
* the Host to stop the listed work and archive; cancelling leaves the
|
||||
* Session running and visible.
|
||||
* @param props - the request hook, its settlement, the stop-and-archive hop, and the locale seat.
|
||||
* @returns the open dialog, or null.
|
||||
*/
|
||||
export function SessionArchiveConfirmDialog({
|
||||
useArchiveRequest, settleSessionArchive, stopAndArchiveSession, t,
|
||||
}: SessionArchiveConfirmProps) {
|
||||
const request = useArchiveRequest(pending => pending)
|
||||
if (request === null) return null
|
||||
return (
|
||||
<ArchiveConfirmForm
|
||||
key={request.sessionId}
|
||||
request={request}
|
||||
stopAndArchiveSession={stopAndArchiveSession}
|
||||
onSettle={settleSessionArchive}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** One request's dialog: in-flight and error state die with it. */
|
||||
function ArchiveConfirmForm({ request, stopAndArchiveSession, onSettle, t }: {
|
||||
request: SessionArchiveConfirmRequest
|
||||
stopAndArchiveSession: SessionArchiveConfirmInjected['stopAndArchiveSession']
|
||||
onSettle: () => void
|
||||
t: SessionArchiveConfirmProps['t']
|
||||
}) {
|
||||
const [archiving, setArchiving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const close = () => {
|
||||
if (archiving) return
|
||||
onSettle()
|
||||
}
|
||||
const confirm = () => {
|
||||
setArchiving(true)
|
||||
setError(null)
|
||||
stopAndArchiveSession(request.sessionId).then(() => {
|
||||
setArchiving(false)
|
||||
onSettle()
|
||||
}).catch((reason: unknown) => {
|
||||
setArchiving(false)
|
||||
setError(reason instanceof Error ? reason.message : String(reason))
|
||||
})
|
||||
}
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={close}
|
||||
closeLabel={t('close')}
|
||||
title={t('archive.confirm.title')}
|
||||
description={t('archive.confirm.desc', { title: request.displayTitle })}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" disabled={archiving} onClick={close}>{t('cancel')}</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={browserCss.deleteAction}
|
||||
disabled={archiving}
|
||||
onClick={confirm}
|
||||
>
|
||||
{t('archive.confirm.action')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<ul className={browserCss.archiveActivity} aria-label={t('archive.confirm.activity')}>
|
||||
{request.activity.map((entry, index) => (
|
||||
<li key={`${entry.kind}-${String(index)}`}>{activityLine(entry, t)}</li>
|
||||
))}
|
||||
</ul>
|
||||
{archiving && <div className={browserCss.deleteStatus} role="status">{t('archive.confirm.pending')}</div>}
|
||||
{error !== null && <div className={browserCss.renameError} role="alert">{error}</div>}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One family's line: its count and the items' labels (ids when a family
|
||||
* carries no label). A family this dictionary does not know — a provider
|
||||
* merged into the kind map — falls through to the generic line.
|
||||
*/
|
||||
function activityLine(entry: SessionActivity, t: SessionArchiveConfirmProps['t']): string {
|
||||
const items = entry.items ?? []
|
||||
const n = items.length
|
||||
const names = items.map(item => item.label ?? item.id).join(t('archive.confirm.listSeparator'))
|
||||
const plural = n === 1 ? 'one' : 'other'
|
||||
switch (entry.kind) {
|
||||
case 'turn': return t('archive.confirm.turn')
|
||||
case 'subagent': return t(`archive.confirm.subagents.${plural}`, { n, names })
|
||||
case 'job': return t(`archive.confirm.jobs.${plural}`, { n, names })
|
||||
case 'schedule': return t(`archive.confirm.schedules.${plural}`, { n, names })
|
||||
default: return t(`archive.confirm.other.${plural}`, { kind: entry.kind, n })
|
||||
}
|
||||
}
|
||||
@@ -14,22 +14,22 @@ import type { RowToastProps, RowToastState } from '../contract/slots.ts'
|
||||
const LONG_TOAST_HOLD_MS = 6000
|
||||
|
||||
/**
|
||||
* Render the current notice: the archived notice with its undo
|
||||
* and show-archived actions on a 6 s hold, a refused Session creation with
|
||||
* the Host's reason on the same hold, or a plain warning for a failed
|
||||
* pin, an archived row that was clicked, or default Workspace creation.
|
||||
* Render the current notice: the archived and stopped-and-archived notices
|
||||
* with their undo and show-archived actions on a 6 s hold, a refused Session
|
||||
* creation with the Host's reason on the same hold, or a plain warning for a
|
||||
* failed pin, an archived row that was clicked, or default Workspace creation.
|
||||
* @param props - the notice hook, its dismissal, the two archived-notice actions, and the locale seat.
|
||||
* @returns the notice on display, or null.
|
||||
*/
|
||||
export function RowActionToast({ useToast, dismissToast, undoArchive, showArchived, t }: RowToastProps) {
|
||||
const toast = useToast(current => current)
|
||||
if (toast === null) return null
|
||||
if (toast.kind === 'archived') {
|
||||
if (toast.kind === 'archived' || toast.kind === 'stoppedAndArchived') {
|
||||
const { sessionId } = toast
|
||||
return (
|
||||
<Toast
|
||||
key={`toast-${String(toast.seq)}`}
|
||||
text={t('toast.archived')}
|
||||
text={t(toast.kind === 'archived' ? 'toast.archived' : 'toast.stoppedAndArchived')}
|
||||
tone="success"
|
||||
holdMs={LONG_TOAST_HOLD_MS}
|
||||
actions={[
|
||||
@@ -63,7 +63,7 @@ export function RowActionToast({ useToast, dismissToast, undoArchive, showArchiv
|
||||
|
||||
/** The copy of one plain warning, keyed by the notice kind the union closes over. */
|
||||
function plainNoticeText(
|
||||
toast: Exclude<RowToastState, { kind: 'archived' | 'createFailed' }>,
|
||||
toast: Exclude<RowToastState, { kind: 'archived' | 'stoppedAndArchived' | 'createFailed' }>,
|
||||
t: RowToastProps['t'],
|
||||
): string {
|
||||
switch (toast.kind) {
|
||||
|
||||
@@ -13,10 +13,11 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import {
|
||||
type ArchiveSessionInjected, type ForkSessionInjected, menuOpenStateFactory, type PinSessionInjected,
|
||||
type RenameSessionInjected, type RowToastInjected, type SessionRenameDialogInjected, type WorkspaceViewStoreHandle,
|
||||
type RenameSessionInjected, type RowToastInjected, type SessionArchiveConfirmInjected, type SessionRenameDialogInjected,
|
||||
type WorkspaceViewStoreHandle,
|
||||
} from '../src/client/contract/slots.ts'
|
||||
import { WorkspaceBrowser } from '../src/client/rows/WorkspaceBrowser.tsx'
|
||||
import { ArchiveSessionMenuItem, ArchiveSessionRowButton } from '../src/client/session-actions/ArchiveSession.tsx'
|
||||
import { ArchiveSessionMenuItem, ArchiveSessionRowButton, SessionArchiveConfirmDialog } from '../src/client/session-actions/ArchiveSession.tsx'
|
||||
import { ForkSessionMenuItem } from '../src/client/session-actions/ForkSession.tsx'
|
||||
import { PinSessionMenuItem, PinSessionRowButton } from '../src/client/session-actions/PinSession.tsx'
|
||||
import { RenameSessionMenuItem, SessionRenameDialog } from '../src/client/session-actions/RenameSession.tsx'
|
||||
@@ -216,7 +217,7 @@ describe('ui-workspace apply', () => {
|
||||
// The row actions follow the browser's own declaration, whenever it lands.
|
||||
expect(after.slots.entries(MENU_ITEM)).toHaveLength(4)
|
||||
expect(after.slots.entries(ROW_ACTION)).toHaveLength(2)
|
||||
expect(after.slots.entries('shell.overlay')).toHaveLength(2)
|
||||
expect(after.slots.entries('shell.overlay')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('declares the two Session row lists and registers the shipped actions and overlay surfaces into them', async () => {
|
||||
@@ -244,6 +245,7 @@ describe('ui-workspace apply', () => {
|
||||
])
|
||||
expect(rows('shell.overlay')).toEqual([
|
||||
['workspace.session-rename', undefined, SessionRenameDialog, 'workspace'],
|
||||
['workspace.session-archive', undefined, SessionArchiveConfirmDialog, 'workspace'],
|
||||
['workspace.row-toast', undefined, RowActionToast, 'workspace'],
|
||||
])
|
||||
// Only the browser declares the viewing store; its handle hands out the
|
||||
@@ -388,6 +390,44 @@ describe('ui-workspace apply', () => {
|
||||
expect(toast.hooks.toast.getSnapshot()).toEqual({ kind: 'archived', sessionId: 'one', seq: 1 })
|
||||
})
|
||||
|
||||
it('turns the Host\'s running-work refusal into the stop-and-archive confirmation, which archives with stopActivity', async () => {
|
||||
const b = await bench()
|
||||
b.setSessions(sessionState([{ ...summary('busy', 3), displayTitle: 'Busy session' }]))
|
||||
declare(b.slots, 'sidebar.workspaces', 'shell.overlay')
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const activity = [{ kind: 'turn' as const }, { kind: 'job' as const, items: [{ id: 'bash-1', label: 'pnpm run build' }] }]
|
||||
const refusal = Object.assign(new Error('workspace session archive failed: workspace/session-active: active'), {
|
||||
name: 'WorkspaceArchiveError',
|
||||
rpcError: new RemoteError('workspace/session-active', 'active', { sessionId: sid('busy'), activity }),
|
||||
})
|
||||
const archiveSession = vi.spyOn(b.ctx.uiWorkspace, 'archiveSession')
|
||||
.mockRejectedValueOnce(refusal)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
const archive = faceOf(entry(b.slots, ROW_ACTION, 'archive')) as ArchiveSessionInjected
|
||||
const confirm = faceOf(entry(b.slots, 'shell.overlay', 'workspace.session-archive')) as SessionArchiveConfirmInjected
|
||||
const toast = faceOf(entry(b.slots, 'shell.overlay', 'workspace.row-toast')) as RowToastInjected
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
archive.archiveSession(sid('busy'))
|
||||
await vi.waitFor(() => {
|
||||
expect(confirm.hooks.archiveRequest.getSnapshot()).toEqual({ sessionId: 'busy', displayTitle: 'Busy session', activity })
|
||||
})
|
||||
// The refusal is a question, not a diagnostic, and nothing is archived yet.
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
expect(toast.hooks.toast.getSnapshot()).toBeNull()
|
||||
expect(archiveSession).toHaveBeenCalledWith('busy')
|
||||
|
||||
// Cancelling settles the request; confirming asks the Host to stop the work.
|
||||
confirm.settleSessionArchive()
|
||||
expect(confirm.hooks.archiveRequest.getSnapshot()).toBeNull()
|
||||
await confirm.stopAndArchiveSession(sid('busy'))
|
||||
expect(archiveSession).toHaveBeenLastCalledWith('busy', { stopActivity: true })
|
||||
expect(toast.hooks.toast.getSnapshot()).toEqual({ kind: 'stoppedAndArchived', sessionId: 'busy', seq: 1 })
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('the notice share takes the notice down, undoes an archive, and shows the archived rows', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots, 'sidebar.workspaces', 'shell.overlay')
|
||||
@@ -551,7 +591,7 @@ describe('ui-workspace apply', () => {
|
||||
await fiber.await()
|
||||
expect(b.slots.entries(MENU_ITEM)).toHaveLength(4)
|
||||
expect(b.slots.entries(ROW_ACTION)).toHaveLength(2)
|
||||
expect(b.slots.entries('shell.overlay')).toHaveLength(2)
|
||||
expect(b.slots.entries('shell.overlay')).toHaveLength(3)
|
||||
await fiber.dispose()
|
||||
expect(b.slots.entries('sidebar.workspaces')).toHaveLength(0)
|
||||
expect(b.slots.entries('conversation.hero.workspace')).toHaveLength(0)
|
||||
|
||||
@@ -17,22 +17,27 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { GlobalStandardProps, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import type {
|
||||
MenuOpenState, RowToast, RowToastState, SessionRenameDialogInjected, SessionRenameTarget,
|
||||
MenuOpenState, RowToast, RowToastState, SessionArchiveConfirmInjected, SessionArchiveConfirmRequest,
|
||||
SessionRenameDialogInjected, SessionRenameTarget,
|
||||
} from '../src/client/contract/slots.ts'
|
||||
import { ArchiveSessionMenuItem, ArchiveSessionRowButton } from '../src/client/session-actions/ArchiveSession.tsx'
|
||||
import {
|
||||
ArchiveSessionMenuItem, ArchiveSessionRowButton, SessionArchiveConfirmDialog,
|
||||
} from '../src/client/session-actions/ArchiveSession.tsx'
|
||||
import { ForkSessionMenuItem } from '../src/client/session-actions/ForkSession.tsx'
|
||||
import { PinSessionMenuItem, PinSessionRowButton } from '../src/client/session-actions/PinSession.tsx'
|
||||
import { RenameSessionMenuItem, SessionRenameDialog } from '../src/client/session-actions/RenameSession.tsx'
|
||||
import { RowActionToast } from '../src/client/session-actions/RowActionToast.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
// The seat's key domain is workspace ∪ common; the stub mirrors the real
|
||||
// lookup chain (namespace, then common vocabulary, then the key).
|
||||
const t: PropsLocale<'workspace'>['t'] = makeTranslate(zh, commonZh)
|
||||
const tEn: PropsLocale<'workspace'>['t'] = makeTranslate(en, commonEn)
|
||||
|
||||
const sid = (id: string) => id as SessionId
|
||||
/** Selector hook over one fixed snapshot: how the renderer binds a standard or injected `hooks` source. */
|
||||
@@ -334,6 +339,122 @@ describe('SessionRenameDialog', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionArchiveConfirmDialog', () => {
|
||||
/** The dialog over a test-owned request source; settling clears the request the way apply does. */
|
||||
function archiveDialog(stopAndArchiveSession: SessionArchiveConfirmInjected['stopAndArchiveSession'], translate = t) {
|
||||
const request = createSnapshotStore<SessionArchiveConfirmRequest | null>(null)
|
||||
const settleSessionArchive = vi.fn(() => { request.set(null) })
|
||||
render(
|
||||
<SessionArchiveConfirmDialog
|
||||
{...overlay}
|
||||
t={translate}
|
||||
useArchiveRequest={bindSnapshotSelector(request)}
|
||||
settleSessionArchive={settleSessionArchive}
|
||||
stopAndArchiveSession={stopAndArchiveSession}
|
||||
/>,
|
||||
)
|
||||
const ask = (activity: SessionArchiveConfirmRequest['activity']): void => {
|
||||
act(() => { request.set({ sessionId: sid('one'), displayTitle: 'Busy session', activity }) })
|
||||
}
|
||||
return { settleSessionArchive, ask }
|
||||
}
|
||||
|
||||
it('renders nothing until a confirmation is requested', () => {
|
||||
archiveDialog(vi.fn(async () => {}))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('names the session and lists every reported family with its items, then stops and archives on confirm', async () => {
|
||||
const pending = Promise.withResolvers<undefined>()
|
||||
const stopAndArchiveSession = vi.fn(() => pending.promise)
|
||||
const { settleSessionArchive, ask } = archiveDialog(stopAndArchiveSession)
|
||||
ask([
|
||||
{ kind: 'turn' },
|
||||
{ kind: 'subagent', items: [{ id: 'child-1', label: 'reviewer' }, { id: 'child-2' }] },
|
||||
{ kind: 'job', items: [{ id: 'bash-1', label: 'pnpm run build' }] },
|
||||
{ kind: 'schedule', items: [{ id: 'schedule-1', label: 'check the build' }] },
|
||||
])
|
||||
const dialog = screen.getByRole('dialog', { name: '停止并归档此会话?' })
|
||||
expect(dialog.textContent).toContain('“Busy session”仍有正在进行的工作')
|
||||
const lines = [...screen.getByRole('list', { name: '将被停止的工作' }).querySelectorAll('li')].map(li => li.textContent)
|
||||
expect(lines).toEqual([
|
||||
'进行中的回合',
|
||||
'2 个运行中的子代理:reviewer、child-2',
|
||||
'1 个后台任务:pnpm run build',
|
||||
'1 条定时提醒:check the build',
|
||||
])
|
||||
fireEvent.click(screen.getByRole('button', { name: '停止并归档' }))
|
||||
expect(stopAndArchiveSession).toHaveBeenCalledWith(sid('one'))
|
||||
// While the Host call is pending, closing is blocked and the status shows.
|
||||
expect(screen.getByRole('status').textContent).toBe('正在停止并归档…')
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }))
|
||||
expect(settleSessionArchive).not.toHaveBeenCalled()
|
||||
await act(async () => { pending.resolve(undefined) })
|
||||
expect(settleSessionArchive).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the dialog open with a rejection surfaced, and Cancel settles without archiving', async () => {
|
||||
const stopAndArchiveSession = vi.fn<SessionArchiveConfirmInjected['stopAndArchiveSession']>()
|
||||
.mockRejectedValueOnce(new Error('stop exploded'))
|
||||
const { settleSessionArchive, ask } = archiveDialog(stopAndArchiveSession)
|
||||
ask([{ kind: 'turn' }])
|
||||
fireEvent.click(screen.getByRole('button', { name: '停止并归档' }))
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('stop exploded') })
|
||||
expect(settleSessionArchive).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }))
|
||||
expect(settleSessionArchive).toHaveBeenCalledOnce()
|
||||
expect(stopAndArchiveSession).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('ignores Escape while the Host call is pending and reports a non-Error reason as text', async () => {
|
||||
const pending = Promise.withResolvers<undefined>()
|
||||
const stopAndArchiveSession = vi.fn<SessionArchiveConfirmInjected['stopAndArchiveSession']>()
|
||||
.mockReturnValueOnce(pending.promise)
|
||||
const { settleSessionArchive, ask } = archiveDialog(stopAndArchiveSession)
|
||||
ask([{ kind: 'turn' }])
|
||||
fireEvent.click(screen.getByRole('button', { name: '停止并归档' }))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(settleSessionArchive).not.toHaveBeenCalled()
|
||||
await act(async () => { pending.reject('plain failure') })
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('plain failure') })
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(settleSessionArchive).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('describes a family merged by another provider with the generic line', () => {
|
||||
const { ask } = archiveDialog(vi.fn(async () => {}))
|
||||
ask([{ kind: 'probe', items: [{ id: 'probe-1' }] }])
|
||||
const lines = [...screen.getByRole('list', { name: '将被停止的工作' }).querySelectorAll('li')].map(li => li.textContent)
|
||||
expect(lines).toEqual(['1 项其他工作(probe)'])
|
||||
})
|
||||
|
||||
it('selects the singular or plural English line by item count', () => {
|
||||
const { ask } = archiveDialog(vi.fn(async () => {}), tEn)
|
||||
ask([
|
||||
{ kind: 'subagent', items: [{ id: 'child-1', label: 'reviewer' }] },
|
||||
{ kind: 'job', items: [{ id: 'bash-1' }, { id: 'bash-2' }] },
|
||||
{ kind: 'schedule', items: [{ id: 'schedule-1', label: 'check the build' }, { id: 'schedule-2', label: 'stand-up' }] },
|
||||
{ kind: 'probe', items: [{ id: 'probe-1' }] },
|
||||
])
|
||||
const lines = [...screen.getByRole('list', { name: 'Work that will be stopped' }).querySelectorAll('li')].map(li => li.textContent)
|
||||
expect(lines).toEqual([
|
||||
'1 running subagent: reviewer',
|
||||
'2 background jobs: bash-1, bash-2',
|
||||
'2 scheduled reminders: check the build, stand-up',
|
||||
'1 other item of work (probe)',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
// A provider outside this package may merge its own family into the kind map;
|
||||
// the dialog must describe it without knowing its copy.
|
||||
declare module '@deepseek-ai/dsh-workspace/types' {
|
||||
interface SessionActivityKindMap {
|
||||
probe: true
|
||||
}
|
||||
}
|
||||
|
||||
describe('RowActionToast', () => {
|
||||
/** The notice surface over a test-owned notice source; dismissal clears the notice the way apply does. */
|
||||
function toastSurface() {
|
||||
@@ -363,6 +484,14 @@ describe('RowActionToast', () => {
|
||||
expect(document.body.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('the stopped-and-archived notice offers the same undo and filter actions under its own wording', () => {
|
||||
const { undoArchive, notify } = toastSurface()
|
||||
notify({ kind: 'stoppedAndArchived', sessionId: sid('one') })
|
||||
expect(screen.getByRole('alert').textContent).toBe('已停止并归档,可撤销或筛选已归档会话')
|
||||
fireEvent.click(screen.getByRole('button', { name: '撤销' }))
|
||||
expect(undoArchive).toHaveBeenCalledWith(sid('one'))
|
||||
})
|
||||
|
||||
it('the archived notice takes itself down, then undoes the archive or shows the archived rows', () => {
|
||||
const { dismissToast, undoArchive, showArchived, notify } = toastSurface()
|
||||
notify({ kind: 'archived', sessionId: sid('one') })
|
||||
|
||||
@@ -35,12 +35,18 @@
|
||||
{
|
||||
"path": "../store"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../jobs/jobs"
|
||||
},
|
||||
{
|
||||
"path": "../../schedule/schedule"
|
||||
},
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/agent/README.md
|
||||
README.md: 8ba3dc730790acb0f792173e9ee01d2dc954957e
|
||||
README.zh.md: 0718ae5f3f73b1fd0bb5b600de3a08c50e67421e
|
||||
README.md: be003d7b899306b58c4ddc88e720c18dde9ce33b
|
||||
README.zh.md: e360ba975dddc381e25b69766b1df82f6e45b3b4
|
||||
@@ -101,11 +101,14 @@ The package is built on one separation: the public `Agent` surface and registry
|
||||
| [`src/consumed-work.ts`](src/consumed-work.ts) | `foldConsumedWork(events)`: what the log's consumed work became |
|
||||
| [`src/model-selection.ts`](src/model-selection.ts) | `installModelSelection`: coupling one selection to assembly and routing |
|
||||
| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: no-op `agent/status` transitions fail |
|
||||
| [`src/archive-admission.ts`](src/archive-admission.ts) | The `turn` family of the Workspace registry's archive admission: a running turn and its user-cause cancel |
|
||||
|
||||
### Registry and lifecycle
|
||||
|
||||
`AgentRegistry` keeps one entry per live agent with its carrier and creator relation. Await `register()` to finish serial creation listeners with source `startup` before using an already-constructed agent; the async factory uses the split `enter()`/`announce()` pair so setup and initialization stay rollback-covered. A detach requested during creation waits for every awaited listener to settle, and each detach is bound to the exact entry, so a stale disposer cannot remove a later same-id replacement. Teardown stops and drains the loop, unwinds the scope, detaches the agent, then detaches the session; the id becomes reusable after private cleanup.
|
||||
|
||||
The registry also answers the Workspace registry's archive admission ([seam](../../workspace/workspace/README.md)) for every Agent it publishes: `workspace/session-activity` reports the `turn` family while the Session's Agent is running, a turn waiting for an approval or an answer included, and `workspace/session-stop` cancels that turn the way the user's own stop does — `agent.cancel({ kind: 'user' })`, but without the stop button's `keepInbox`, so queued input is discarded with a logged inbox splice instead of waking the archived Session later. Nothing is awaited to settlement; a Session without a live Agent has no turn that could run. The `turn` key is merged into `SessionActivityKindMap` from [`src/types.ts`](src/types.ts), so a Client that renders the families imports `@deepseek-ai/dsh-agent/types` for it.
|
||||
|
||||
### Initiator scope
|
||||
|
||||
Each driver runs its complete lifetime inside `ctx.agents.withInitiator(agent, ...)`, so inherited async chains observe that agent; `withoutInitiator()` hides it for unrelated process-local work such as shared timers. The boundary is process-local attribution only — ambient presence is neither liveness proof nor authorization, and explicit identity stays authoritative at worker, process, persistence, and wire boundaries. Teardown rejects new boundaries, lets returned-Promise boundaries drain, then disables the underlying storage. The [initiator-scope decision](../../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md) owns the detailed contract.
|
||||
|
||||
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user