Merge remote-tracking branch 'origin/master' into worktree/preset-plane-fallout-p1
Generated docs (`module-graph`, `event-producer-consumer`) taken from master and regenerated. The `cordis-inspect-jsdoc` golden likewise: master's copy is the base, and this branch's `presentAs` per-scope rewording is re-applied on top, since `cordis_inspect` renders that JSDoc into model-visible output. Hook bypassed as before: the staged-pairing check hands an archived note path to `verify-translation-pairing`. The full-corpus gate passes.
This commit is contained in:
@@ -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/README.md
|
||||
README.md: 19d6e5ba7b554f59bd66e213f8a53389761fc735
|
||||
README.zh.md: 17f58a2922e9019af054b0dccb6c4d9199fd1a9d
|
||||
README.md: eb7df95bde10dafd7afcb168d30c9dda90296687
|
||||
README.zh.md: 03cd02510267d3abdd414bed6ec1f42773d0811a
|
||||
|
||||
@@ -52,7 +52,6 @@ Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Gr
|
||||
| [`boot/`](boot/README.md) | Shared app-bin boot glue | Product — stable surface |
|
||||
| [`host/`](host/README.md) | Web-GUI host half: API gateway + HTTP route server | Product — stable surface |
|
||||
| [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins | Product — stable surface |
|
||||
| [`experimental/`](experimental/README.md) | Prototypes and internal plugins | Unreleased |
|
||||
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + CLI/ACP/JSON-RPC bins) leaves load | Support — example infra |
|
||||
| [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations |
|
||||
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded<B>`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free |
|
||||
|
||||
@@ -52,7 +52,6 @@ npm scope 为 `@deepseek-ai/dsh-*`;Cordis `Service` 子类和函数插件通
|
||||
| [`boot/`](boot/README.md) | 共享的 app bin 启动粘合层 | 产品:稳定接口 |
|
||||
| [`host/`](host/README.md) | web GUI 宿主半侧:API 网关 + HTTP 路由服务器 | 产品:稳定接口 |
|
||||
| [`client/`](client/README.md) | web GUI 浏览器半侧:shell、协议层、对象服务、slot、`ui-*` 插件 | 产品:稳定接口 |
|
||||
| [`experimental/`](experimental/README.md) | 原型和内部插件 | 未发布 |
|
||||
| [`examples/`](examples/README.md) | 演示组合包(agent-spine + CLI/ACP/JSON-RPC bin),由叶节点加载 | 支持:示例基础设施 |
|
||||
| [`support/`](support/README.md) | 支持基础设施(testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 |
|
||||
| [`util/`](util/README.md) | 组间共享的低层零依赖工具(`Branded<B>`、Harness home/路径辅助函数、超时、保留策略) | 支持:小型、稳定、无 harness 依赖 |
|
||||
|
||||
@@ -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/boot/app-boot/README.md
|
||||
README.md: 1d56b2b6d22c08574f8e361955bee1dbe2aca601
|
||||
README.zh.md: 5429a1322d0311f03c7c43946753a290e28cd936
|
||||
README.md: 9639f1c0a2ffe91fd509a2ffdf04be5f0895b700
|
||||
README.zh.md: e8bf0374aad2be2311b6e72e91e41f02f403b48a
|
||||
|
||||
@@ -29,7 +29,7 @@ The Loader mounts entries concurrently, so a surface can already own the termina
|
||||
|
||||
`cordis:group` is registered beside `cordis:include` so a composition can give one `isolate` realm to a provider and its consumers together. Both load through the ambient module pipeline rather than the included tree's own specifier resolution, which is what lets a composition outside this workspace — an agent preset under the Harness home — use a group row at all.
|
||||
|
||||
Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. They resolve from the config directory by default; a closed runtime passes `bareModuleBaseUrl` to `boot` or `mountRootInclude` so its installed package tree remains authoritative even when the config lives inside another Node project. Relative specifiers always resolve against the config directory. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every shipped raw/Web bare plugin to appear in the resolver manifest's `dependencies`.
|
||||
Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. They resolve from the config directory by default; a closed runtime passes `bareModuleBaseUrl` to `boot` or `mountRootInclude` so its installed package tree remains authoritative even when the config lives inside another Node project. Relative specifiers always resolve against the config directory. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `pnpm dsh` source path additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every shipped raw/Web bare plugin to appear in the resolver manifest's `dependencies`.
|
||||
|
||||
This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution.
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面
|
||||
|
||||
`cordis:group` 与 `cordis:include` 一并注册,使一份组装能把一个提供方与它的消费方放进同一个 `isolate` realm。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析,这正是让本工作区之外的组装——放在 Harness home 下的 agent preset——能够使用 group 行的原因。
|
||||
|
||||
配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包)通过 Cordis Loader 的内部模块 loader 解析。默认情况下,它们从配置目录解析;封闭运行时会向 `boot` 或 `mountRootInclude` 传入 `bareModuleBaseUrl`,使已安装包树保持权威,即使配置位于另一个 Node 项目中也不受遮蔽。相对 specifier 始终以配置目录为基准解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与宿主会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个随附的原始/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。
|
||||
配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包)通过 Cordis Loader 的内部模块 loader 解析。默认情况下,它们从配置目录解析;封闭运行时会向 `boot` 或 `mountRootInclude` 传入 `bareModuleBaseUrl`,使已安装包树保持权威,即使配置位于另一个 Node 项目中也不受遮蔽。相对 specifier 始终以配置目录为基准解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与宿主会绑定到同一个 Loader peer。`pnpm dsh` 源码路径还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个随附的原始/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。
|
||||
|
||||
此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。
|
||||
|
||||
|
||||
@@ -202,6 +202,10 @@
|
||||
- id: ui-subagent
|
||||
name: '@deepseek-ai/dsh-client-ui-subagent'
|
||||
|
||||
# Background tasks: the session-header list over the tasksBySession mirror.
|
||||
- id: ui-task
|
||||
name: '@deepseek-ai/dsh-client-ui-task'
|
||||
|
||||
# Goal surface: GoalBar in the input dock over the goal session projection.
|
||||
- id: ui-goal
|
||||
name: '@deepseek-ai/dsh-client-ui-goal'
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-task": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-tool": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
|
||||
|
||||
@@ -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/README.md
|
||||
README.md: 567e10f74ae9d017abef1d876401a958eb80fcfd
|
||||
README.zh.md: ad6a9fb199c4118b864b80a466ddef40676b7169
|
||||
README.md: bbc32fb3944dcb3b7aa48ef1f8e24e5c93ff7a67
|
||||
README.zh.md: 5bfbd1ce6b41a44d3ef421ea59ecc29e1c329b3c
|
||||
|
||||
@@ -29,6 +29,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
|
||||
| [`ui-slash/`](ui-slash/README.md) | Coordinates inline command and reference suggestions. |
|
||||
| [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions. |
|
||||
| [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references. |
|
||||
| [`ui-task/`](ui-task/README.md) | Lists this session's background tasks in the conversation header. |
|
||||
| [`ui-model/`](ui-model/README.md) | Provides model selection in conversation surfaces. |
|
||||
| [`ui-permission/`](ui-permission/README.md) | Configures default permissions and switches the current session's access. |
|
||||
| [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. |
|
||||
|
||||
@@ -29,6 +29,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U
|
||||
| [`ui-slash/`](ui-slash/README.md) | 协调内联命令和引用建议。 |
|
||||
| [`ui-skill/`](ui-skill/README.md) | 向内联建议添加 skill(技能)引用。 |
|
||||
| [`ui-subagent/`](ui-subagent/README.md) | 提供 subagent 导航、子会话记录状态和内联引用。 |
|
||||
| [`ui-task/`](ui-task/README.md) | 在会话标题栏列出当前会话的后台任务。 |
|
||||
| [`ui-model/`](ui-model/README.md) | 在会话界面中提供模型选择。 |
|
||||
| [`ui-permission/`](ui-permission/README.md) | 配置默认权限并切换当前会话的访问模式。 |
|
||||
| [`ui-plan/`](ui-plan/README.md) | 展示生效中的 plan mode 状态及其退出控件。 |
|
||||
|
||||
@@ -17,6 +17,7 @@ export type {
|
||||
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
|
||||
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
|
||||
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
|
||||
TaskView,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
export type {
|
||||
|
||||
@@ -22,6 +22,7 @@ export type {
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
MessageId, ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels,
|
||||
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
|
||||
TaskView,
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
|
||||
|
||||
@@ -16,7 +16,7 @@ const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }]
|
||||
/** Empty global standard-kit hooks (the row reads neither). */
|
||||
function emptySessions() {
|
||||
const store = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined })
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
function emptyWorkspaces() {
|
||||
|
||||
@@ -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/runtime/README.md
|
||||
README.md: d84cd793c34242759ad04edf0debb91558ec3dfc
|
||||
README.zh.md: e7a74c454f24fcec5e797427c21222b1dc258b44
|
||||
README.md: 7c835deb58db149710495f97a2553c3de58d99da
|
||||
README.zh.md: edf4473bec7df2253c032c3da86da878cdeade09
|
||||
|
||||
@@ -25,6 +25,8 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
|
||||
|
||||
`indexSubagentDescendants()` derives per-parent total and running descendant counts from the retained list mirror. It follows only uninterrupted `origin: 'subagent'` ancestry, so an ordinary fork starts a separate ownership subtree; cycles stop without throwing, and a missing parent remains a harmless key until its summary arrives.
|
||||
|
||||
`SessionListState.tasksBySession` mirrors the Host's `session/tasks` frames last-wins, keyed by session and needing no Session instance. An emptied set is stored as an absent key, so absence and `[]` are one representation and consumers never test a sentinel. Two clears keep it from outliving its truth: `session/subscribed` drops the session's mirror, because a fresh generation sends a baseline only for a non-empty set and a retained list would survive as a phantom, and `host/session-removed` drops it again, because owner disposal removed the records on the mux stream while the removal frame rides the host stream, leaving the two with no relative order.
|
||||
|
||||
`SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it.
|
||||
|
||||
## New Session and the blank mirror
|
||||
|
||||
@@ -25,6 +25,8 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
|
||||
`indexSubagentDescendants()` 从保留的列表镜像中派生每个 parent 的后代总数与运行中后代数。它只沿不间断的 `origin: 'subagent'` 祖先链追踪,因此普通 fork 会开启独立的归属子树;遇到环时,追踪会停止但不会抛出异常,缺失的 parent 则会保留为无害的键,直至其摘要到达。
|
||||
|
||||
`SessionListState.tasksBySession` 按 last-wins 镜像宿主的 `session/tasks` 帧,以会话为键,不需要 Session 实例。被清空的集合存为缺失的键,因此「缺失」与 `[]` 是同一种表示,消费方永远不必检测哨兵值。两处清理让它不至于比它所反映的真相活得更久:`session/subscribed` 丢弃该会话的镜像,因为新一代只为非空集合发送 baseline,被留下的列表会变成幽灵;`host/session-removed` 再丢一次,因为 owner 销毁是在 mux 流上移除记录的,而移除帧走 host 流,两者没有相对顺序。
|
||||
|
||||
`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将 `SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。
|
||||
|
||||
## New Session 与 blank 镜像
|
||||
|
||||
@@ -53,7 +53,7 @@ export type {
|
||||
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
|
||||
} from './sessions/service.ts'
|
||||
export type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './sessions/manager.ts'
|
||||
export type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
|
||||
export type { SubagentAddress, TaskView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
export type { WorkspaceListPhase } from './workspaces/manager.ts'
|
||||
export type { WorkspaceListState } from './workspaces/service.ts'
|
||||
export type {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import type {
|
||||
IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId,
|
||||
SessionSummary, SubagentAddress, SubagentCatalog, WorkspaceId,
|
||||
SessionSummary, SubagentAddress, SubagentCatalog, TaskView, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
@@ -48,6 +48,8 @@ export interface SessionListSnapshot {
|
||||
phase: SessionListPhase
|
||||
error: RpcError | null
|
||||
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
|
||||
/** Background tasks per session; an absent key is an empty set. */
|
||||
tasksBySession: Readonly<Record<SessionId, readonly TaskView[]>>
|
||||
currentAddress: SubagentAddress | undefined
|
||||
}
|
||||
|
||||
@@ -138,6 +140,11 @@ export class SessionManager {
|
||||
private readonly catalogStale = new Set<SessionId>()
|
||||
private readonly openCatalogs = new Set<SessionId>()
|
||||
private readonly catalogDebounce = new Map<SessionId, ReturnType<typeof setTimeout>>()
|
||||
/**
|
||||
* Background tasks per session, last-wins from `session/tasks`. An empty set
|
||||
* is stored as an absent key, so absence and `[]` are one representation.
|
||||
*/
|
||||
private readonly tasksBySession = new Map<SessionId, readonly TaskView[]>()
|
||||
|
||||
private selected: SessionId | undefined
|
||||
|
||||
@@ -682,10 +689,23 @@ export class SessionManager {
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
if (frame.type === 'session/tasks') {
|
||||
// Whole-set snapshot, so last-wins with no reconciliation. The Host omits
|
||||
// the baseline for an empty set, which is the same fact an emptying change
|
||||
// reports as `[]` — both land as an absent key.
|
||||
if (frame.tasks.length === 0) this.tasksBySession.delete(frame.sessionId)
|
||||
else this.tasksBySession.set(frame.sessionId, frame.tasks)
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
if (frame.type === 'session/subscribed') {
|
||||
// Rows past the host's durable baseline rode state a restart lost; drop
|
||||
// them so last-wins cannot pin a phantom value over recomputed truth.
|
||||
this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq)
|
||||
// Same re-baseline reasoning as the queue below: this generation sends a
|
||||
// task baseline only when the set is non-empty, so a mirror kept from the
|
||||
// previous generation would survive as a phantom list.
|
||||
this.tasksBySession.delete(frame.sessionId)
|
||||
this.notifier.markDirty()
|
||||
// New mux-generation baseline: discard the previous queue snapshot.
|
||||
// The host omits session/queue when the live queue is empty, so retaining
|
||||
@@ -804,6 +824,11 @@ export class SessionManager {
|
||||
}
|
||||
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
|
||||
this.pendingInteractions.delete(frame.sessionId) // a removed session cannot wait on anyone
|
||||
// Owner disposal already dropped these registry-side, but that lands on
|
||||
// the mux stream while this frame rides the host stream, so the two have
|
||||
// no relative order. Clearing here makes a detached Activation's rows
|
||||
// disappear whichever arrives first.
|
||||
this.tasksBySession.delete(frame.sessionId)
|
||||
if (!durableSubagent) this.projectionStores.delete(frame.sessionId)
|
||||
// A pull already in flight was requested before this removal and can
|
||||
// carry the pre-removal parentAvailable:true, which would resurrect
|
||||
@@ -1040,6 +1065,7 @@ export class SessionManager {
|
||||
phase: this.listPhase,
|
||||
error: this.listError,
|
||||
subagentsByParent: Object.fromEntries(this.catalogs),
|
||||
tasksBySession: Object.fromEntries(this.tasksBySession),
|
||||
currentAddress: current === undefined ? undefined : this.addresses.get(current),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
import type { Context, Fiber } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, WorkspaceId,
|
||||
IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, TaskView, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
@@ -86,6 +86,12 @@ export interface SessionListState {
|
||||
phase: SessionListPhase
|
||||
/** Direct durable catalogs keyed by their selected parent address. */
|
||||
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
|
||||
/**
|
||||
* Background tasks each session can see, mirrored last-wins from
|
||||
* `session/tasks`. A missing key is an empty set — the Host sends no baseline
|
||||
* for a session without tasks — so consumers read absence, never a sentinel.
|
||||
*/
|
||||
tasksBySession: Readonly<Record<SessionId, readonly TaskView[]>>
|
||||
/** Current session's catalog-derived address, absent on ordinary navigation. */
|
||||
currentAddress: SubagentAddress | undefined
|
||||
}
|
||||
@@ -291,7 +297,7 @@ export class SessionsService implements ISessions {
|
||||
)
|
||||
this.list = createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'pending',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
||||
})
|
||||
// The manager owns wire truth; the store is its projection. Manager
|
||||
// notifications are already microtask-batched.
|
||||
@@ -649,7 +655,7 @@ export class SessionsService implements ISessions {
|
||||
/** Project the manager's list snapshot into the store (title derivation is display-only). */
|
||||
private projectList(): void {
|
||||
const {
|
||||
items, current, phase, subagentsByParent, currentAddress,
|
||||
items, current, phase, subagentsByParent, tasksBySession, currentAddress,
|
||||
} = this.manager.getListSnapshot()
|
||||
const ids: SessionId[] = []
|
||||
const byId: Record<SessionId, SessionSummary> = {}
|
||||
@@ -719,7 +725,7 @@ export class SessionsService implements ISessions {
|
||||
...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }),
|
||||
})
|
||||
}
|
||||
this.list.set({ ids, byId, current, phase, subagentsByParent, currentAddress })
|
||||
this.list.set({ ids, byId, current, phase, subagentsByParent, tasksBySession, currentAddress })
|
||||
this.pruneScopes()
|
||||
}
|
||||
|
||||
|
||||
@@ -1110,3 +1110,60 @@ describe('completed reminder', () => {
|
||||
expect(entry(manager, S2)?.completed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('background-task mirror', () => {
|
||||
const view = (over: Partial<{ id: string; status: string; label: string }> = {}) => ({
|
||||
id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5, ...over,
|
||||
})
|
||||
const tasksFrame = (sessionId: SessionId, tasks: unknown[]) =>
|
||||
({ rpcId: 't' as never, payload: { type: 'session/tasks', sessionId, tasks } as never })
|
||||
|
||||
it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
|
||||
manager.handleMuxEnvelope(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })]))
|
||||
const first = manager.getListSnapshot().tasksBySession
|
||||
expect(first[S1]).toEqual([view()])
|
||||
expect(first[S2]?.[0]?.label).toBe('other')
|
||||
|
||||
// Last-wins: the newer whole set replaces, it does not merge.
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, [view({ status: 'completed' })]))
|
||||
expect(manager.getListSnapshot().tasksBySession[S1]).toEqual([view({ status: 'completed' })])
|
||||
})
|
||||
|
||||
it('stores an emptied set as an absent key so absence and [] read alike', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
|
||||
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(true)
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, []))
|
||||
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false)
|
||||
})
|
||||
|
||||
it('clears the mirror on re-subscribe, because a task-free generation sends no baseline', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 's' as never,
|
||||
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 3 },
|
||||
})
|
||||
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false)
|
||||
})
|
||||
|
||||
it('drops the rows when the session is removed, whichever stream lands first', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'a' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
|
||||
manager.handleHostEnvelope({ rpcId: 'r' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
|
||||
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false)
|
||||
})
|
||||
|
||||
it('notifies list subscribers so an open header re-renders without a poll', async () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
const seen = vi.fn()
|
||||
manager.subscribe(seen)
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
|
||||
// The notifier batches on a microtask; the frame itself is already applied.
|
||||
await Promise.resolve()
|
||||
expect(seen).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -202,7 +202,7 @@ export class TestSessions implements ISessions {
|
||||
constructor(private readonly stabilize: Stabilizer, private readonly rootCtx: Context) {
|
||||
this.list = createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
||||
})
|
||||
this.channel = new SessionProvideChannel({
|
||||
rebuildBundles: () => {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 180px;
|
||||
padding: 0 8px;
|
||||
padding: 0 2px 0 0;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-fill-tsp-secondary);
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconAgentPresetOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
// Type-only: pulls the ui-conversation SlotMap merge (the header actions).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { AgentPresetSettingsState } from './settings-store.ts'
|
||||
@@ -57,7 +57,7 @@ export function AgentPresetLabel({
|
||||
const text = option === undefined ? undefined : presetDisplayText(option, t)
|
||||
return (
|
||||
<span className={css.label} title={text?.description ?? t('headerHint')}>
|
||||
<IconThinkOutline16 className={css.icon} />
|
||||
<IconAgentPresetOutline16 size={14} className={css.icon} />
|
||||
{text?.name ?? preset}
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
border-radius: 16px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 13px;
|
||||
@@ -36,6 +36,61 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Introduce cue: the icon eases in on an overshoot-free expo curve (duration
|
||||
matches INTRO_TEXT_DELAY_MS, so the characters start the moment it lands),
|
||||
then the name's characters fade up on a stagger (delays set inline per
|
||||
character). All chars occupy their width from the start, so nothing
|
||||
reflows mid-run. */
|
||||
.introIcon {
|
||||
animation: seat-icon-in 0.15s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@keyframes seat-icon-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Wraps the staggered characters into one flex item, so the chip's gap
|
||||
applies around the name as a whole rather than between characters. */
|
||||
.introText {
|
||||
display: inline-block;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.introChar {
|
||||
display: inline-block;
|
||||
white-space: pre;
|
||||
opacity: 0;
|
||||
animation: seat-char-in 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes seat-char-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.introIcon,
|
||||
.introChar {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconChevronDownOutline14, IconThinkOutline16, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconAgentPresetOutline16, IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
// Type-only: pulls the ui-conversation SlotMap merge (the hero seat).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { AgentPresetSeatState } from './seat-store.ts'
|
||||
@@ -32,6 +32,29 @@ export interface AgentPresetSeatInjected {
|
||||
load: () => Promise<void>
|
||||
/** Stage one preset for the next session. */
|
||||
select: (id: string) => Promise<void>
|
||||
/** Clear the one-shot introduce cue once the chip has played it. */
|
||||
introduced: () => void
|
||||
}
|
||||
|
||||
/* Introduce timeline: the icon eases in first (the CSS animation shares this
|
||||
duration); the name's characters start fading up the moment it lands, each
|
||||
taking the fade duration to settle. The cue clears after the last one. The
|
||||
stagger is capped twice: per tick for short CJK names, and by one shared
|
||||
reveal window so a long Latin name finishes in the same time as its CJK
|
||||
counterpart instead of dragging the run out per character. */
|
||||
const INTRO_TEXT_DELAY_MS = 150
|
||||
const INTRO_CHAR_STAGGER_MS = 40
|
||||
const INTRO_TEXT_REVEAL_MS = 200
|
||||
const INTRO_CHAR_FADE_MS = 400
|
||||
|
||||
/**
|
||||
* Per-character start offset for the introduce reveal.
|
||||
* @param count - character count of the shown preset name.
|
||||
* @returns milliseconds between successive character starts.
|
||||
*/
|
||||
function introStaggerMs(count: number): number {
|
||||
if (count <= 1) return 0
|
||||
return Math.min(INTRO_CHAR_STAGGER_MS, INTRO_TEXT_REVEAL_MS / (count - 1))
|
||||
}
|
||||
|
||||
/** Full component props. */
|
||||
@@ -45,7 +68,7 @@ export type AgentPresetSeatProps =
|
||||
* @param props - composed slot props.
|
||||
* @returns the chip, or null when the deployment composes no presets.
|
||||
*/
|
||||
export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPresetSeatProps) {
|
||||
export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat, t }: AgentPresetSeatProps) {
|
||||
const state = useAgentPresetSeat(snapshot => snapshot)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
@@ -53,12 +76,54 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPr
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
// Nothing to choose between: the deployment composes no presets and every
|
||||
// session shares the host composition.
|
||||
if (state.options.length === 0 || state.current === '') return null
|
||||
|
||||
const chosen = state.options.find(option => option.id === state.current)
|
||||
const chosenText = chosen === undefined ? undefined : presetDisplayText(chosen, t)
|
||||
const label = chosenText?.name ?? state.current
|
||||
const ready = state.options.length > 0 && state.current !== ''
|
||||
|
||||
// The introduce cue: the pick was staged from another screen (the settings
|
||||
// creator entry), so the chip announces it — the icon eases in and each
|
||||
// character of the name fades up on a stagger (CSS owns the motion; this
|
||||
// effect only arms it and acknowledges the cue once the run is over).
|
||||
const [introducing, setIntroducing] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!state.introduce || !ready) return
|
||||
const characters = Array.from(label)
|
||||
if (characters.length === 0 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
introduced()
|
||||
return
|
||||
}
|
||||
setIntroducing(true)
|
||||
const done = window.setTimeout(() => {
|
||||
setIntroducing(false)
|
||||
introduced()
|
||||
}, INTRO_TEXT_DELAY_MS + (characters.length - 1) * introStaggerMs(characters.length) + INTRO_CHAR_FADE_MS)
|
||||
return () => { window.clearTimeout(done) }
|
||||
}, [state.introduce, ready, label, introduced])
|
||||
|
||||
// Nothing to choose between: the deployment composes no presets and every
|
||||
// session shares the host composition.
|
||||
if (!ready) return null
|
||||
|
||||
// One wrapper span: the chip is a flex row with a gap, so loose character
|
||||
// spans would each pick up the gap between them.
|
||||
const characters = Array.from(label)
|
||||
const stagger = introStaggerMs(characters.length)
|
||||
const shownLabel = introducing
|
||||
? (
|
||||
<span className={css.introText}>
|
||||
{characters.map((character, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={css.introChar}
|
||||
style={{ animationDelay: `${INTRO_TEXT_DELAY_MS + index * stagger}ms` }}
|
||||
>
|
||||
{character}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
: label
|
||||
|
||||
return (
|
||||
<Menu
|
||||
@@ -95,8 +160,8 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPr
|
||||
disabled={state.busy}
|
||||
onClick={() => { setOpen(value => !value) }}
|
||||
>
|
||||
<IconThinkOutline16 className={css.seatIcon} />
|
||||
{chosenText?.name ?? state.current}
|
||||
<IconAgentPresetOutline16 className={introducing ? `${css.seatIcon} ${css.introIcon}` : css.seatIcon} />
|
||||
{shownLabel}
|
||||
<IconChevronDownOutline14 className={css.chevron} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -26,6 +26,12 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Group-to-group breathing room: the section's 12px gap plus 20px reads the
|
||||
two rosters as separate blocks (32px total). */
|
||||
.group + .group {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.groupHead {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
@@ -363,6 +369,7 @@
|
||||
create button vacated. Dashed like the Models page's add affordances: it
|
||||
reads as a place a preset will appear, not a command. */
|
||||
.creatorButton {
|
||||
box-sizing: border-box;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -372,17 +379,18 @@
|
||||
border: 1px dashed var(--dsw-alias-border-l3);
|
||||
border-radius: 12px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.creatorButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.creatorButton:disabled {
|
||||
opacity: 0.5;
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -171,6 +171,30 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
)
|
||||
}
|
||||
|
||||
/* The guided alternative to copying: the self-referential preset can
|
||||
read this very composition and author a new one in conversation.
|
||||
Offered only where that preset is actually on the roster and a
|
||||
session can be landed; without a writable root the draft could
|
||||
never be discovered, so the reason rides the disabled button. */
|
||||
const creatorButton = props.startCreatorDraft !== undefined && state.rows.some(row => row.id === 'cordis')
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.creatorButton}
|
||||
disabled={!state.authorable}
|
||||
title={state.authorable ? undefined : t('duplicateUnavailable')}
|
||||
onClick={() => {
|
||||
props.startCreatorDraft?.()
|
||||
props.close()
|
||||
}}
|
||||
>
|
||||
{/* Same glyph as the Models page's add affordances. */}
|
||||
<IconPlusOutline16 size={14} />
|
||||
{t('creatorDraft')}
|
||||
</button>
|
||||
)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className={css.section}>
|
||||
<h2 className={css.title}>{t('nav')}</h2>
|
||||
@@ -180,147 +204,130 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
const group = state.rows
|
||||
.filter(row => row.trust === trust)
|
||||
.map(row => ({ row, text: presetDisplayText(row, t) }))
|
||||
if (group.length === 0) return null
|
||||
// The custom group is where a preset of one's own will appear, so it
|
||||
// stays on screen even while empty: heading plus the creator entry.
|
||||
const tail = trust === 'user' ? creatorButton : null
|
||||
if (group.length === 0 && tail === null) return null
|
||||
return (
|
||||
<section key={trust} className={css.group}>
|
||||
<h3 className={css.groupHead}>{heading}</h3>
|
||||
<ul className={css.cards}>
|
||||
{group.map(({ row, text }) => (
|
||||
<li
|
||||
key={row.id}
|
||||
className={row.broken !== undefined
|
||||
? `${css.card} ${css.cardBroken}`
|
||||
: row.isDefault ? `${css.card} ${css.cardActive}` : css.card}
|
||||
>
|
||||
{/* The card body IS the control: picking a preset is the
|
||||
{group.length === 0 ? null : (
|
||||
<ul className={css.cards}>
|
||||
{group.map(({ row, text }) => (
|
||||
<li
|
||||
key={row.id}
|
||||
className={row.broken !== undefined
|
||||
? `${css.card} ${css.cardBroken}`
|
||||
: row.isDefault ? `${css.card} ${css.cardActive}` : css.card}
|
||||
>
|
||||
{/* The card body IS the control: picking a preset is the
|
||||
common act, so it should not hide behind a small button.
|
||||
The action row sits outside it — nesting buttons is
|
||||
invalid, and these act on the card rather than select it.
|
||||
A broken preset cannot compose a session, so its body is
|
||||
disabled and the card says why instead of offering it. */}
|
||||
<button
|
||||
type="button"
|
||||
className={css.cardMain}
|
||||
aria-pressed={row.isDefault}
|
||||
disabled={row.isDefault || row.broken !== undefined}
|
||||
// Without this the name is the whole card read aloud —
|
||||
// title, badge, description, id.
|
||||
aria-label={`${row.broken !== undefined ? t('brokenBadge') : row.isDefault ? t('inUse') : t('setDefault')}: ${text.name}`}
|
||||
title={row.broken ?? (row.isDefault ? t('inUse') : t('setDefault'))}
|
||||
onClick={() => { void props.makeDefault(row.id) }}
|
||||
>
|
||||
<span className={css.cardHead}>
|
||||
<span className={css.cardName}>{text.name}</span>
|
||||
{row.broken !== undefined
|
||||
? <span className={css.brokenBadge}>{t('brokenBadge')}</span>
|
||||
: null}
|
||||
<span className={css.badge}>
|
||||
{row.trust === 'user' ? t('userTrust') : t('builtIn')}
|
||||
<button
|
||||
type="button"
|
||||
className={css.cardMain}
|
||||
aria-pressed={row.isDefault}
|
||||
disabled={row.isDefault || row.broken !== undefined}
|
||||
// Without this the name is the whole card read aloud —
|
||||
// title, badge, description, id.
|
||||
aria-label={`${row.broken !== undefined ? t('brokenBadge') : row.isDefault ? t('inUse') : t('setDefault')}: ${text.name}`}
|
||||
title={row.broken ?? (row.isDefault ? t('inUse') : t('setDefault'))}
|
||||
onClick={() => { void props.makeDefault(row.id) }}
|
||||
>
|
||||
<span className={css.cardHead}>
|
||||
<span className={css.cardName}>{text.name}</span>
|
||||
{row.broken !== undefined
|
||||
? <span className={css.brokenBadge}>{t('brokenBadge')}</span>
|
||||
: null}
|
||||
<span className={css.badge}>
|
||||
{row.trust === 'user' ? t('userTrust') : t('builtIn')}
|
||||
</span>
|
||||
{row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null}
|
||||
</span>
|
||||
{row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null}
|
||||
</span>
|
||||
<span className={css.cardDesc}>{text.description ?? t('noDescription')}</span>
|
||||
{row.broken === undefined
|
||||
? null
|
||||
: <span className={css.cardBrokenReason} role="alert">{row.broken}</span>}
|
||||
<code className={css.cardId}>{row.id}</code>
|
||||
</button>
|
||||
<div className={css.cardFoot}>
|
||||
{/* Shipped presets are the compositions a copy starts
|
||||
<span className={css.cardDesc}>{text.description ?? t('noDescription')}</span>
|
||||
{row.broken === undefined
|
||||
? null
|
||||
: <span className={css.cardBrokenReason} role="alert">{row.broken}</span>}
|
||||
<code className={css.cardId}>{row.id}</code>
|
||||
</button>
|
||||
<div className={css.cardFoot}>
|
||||
{/* Shipped presets are the compositions a copy starts
|
||||
from, so READING one is the point; a custom preset is
|
||||
edited in its files instead, which the location action
|
||||
leads to. A broken shipped preset has no readable
|
||||
composition to offer, so its viewer is withheld; a
|
||||
broken custom one keeps the location action — the
|
||||
files are where it gets fixed. */}
|
||||
{row.trust === 'system'
|
||||
? row.broken === undefined
|
||||
? (
|
||||
{row.trust === 'system'
|
||||
? row.broken === undefined
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
data-tip={t('view')}
|
||||
aria-label={`${t('view')}: ${text.name}`}
|
||||
onClick={() => { void props.view(row.id) }}
|
||||
>
|
||||
<IconBrowseOutline16 />
|
||||
</button>
|
||||
)
|
||||
: null
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
data-tip={t('view')}
|
||||
aria-label={`${t('view')}: ${text.name}`}
|
||||
onClick={() => { void props.view(row.id) }}
|
||||
data-tip={state.hasDocument ? t('openLocation') : t('showLocation')}
|
||||
aria-label={`${state.hasDocument ? t('openLocation') : t('showLocation')}: ${text.name}`}
|
||||
onClick={() => { void props.openLocation(row.id) }}
|
||||
>
|
||||
<IconBrowseOutline16 />
|
||||
<IconFolderOpenOutline16 />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
disabled={!state.authorable || row.broken !== undefined}
|
||||
data-tip={row.broken !== undefined
|
||||
? t('brokenNoCopy')
|
||||
: state.authorable ? t('duplicate') : t('duplicateUnavailable')}
|
||||
aria-label={`${t('duplicate')}: ${text.name}`}
|
||||
onClick={() => { props.beginCopy(row.id) }}
|
||||
>
|
||||
<IconCopyOutline16 />
|
||||
</button>
|
||||
{row.trust === 'user'
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${css.iconButton} ${css.iconDanger}`}
|
||||
data-tip={t('delete')}
|
||||
aria-label={`${t('delete')}: ${text.name}`}
|
||||
onClick={() => { props.confirmDelete(row.id) }}
|
||||
>
|
||||
<IconTrashOutline16 />
|
||||
</button>
|
||||
)
|
||||
: null
|
||||
: null}
|
||||
</div>
|
||||
{state.revealedPaths[row.id] === undefined
|
||||
? null
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
data-tip={state.hasDocument ? t('openLocation') : t('showLocation')}
|
||||
aria-label={`${state.hasDocument ? t('openLocation') : t('showLocation')}: ${text.name}`}
|
||||
onClick={() => { void props.openLocation(row.id) }}
|
||||
>
|
||||
<IconFolderOpenOutline16 />
|
||||
</button>
|
||||
<p className={css.revealedPath}>
|
||||
<span className={css.revealedPathLabel}>{t('revealedPathLabel')}</span>
|
||||
<code>{state.revealedPaths[row.id]}</code>
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
disabled={!state.authorable || row.broken !== undefined}
|
||||
data-tip={row.broken !== undefined
|
||||
? t('brokenNoCopy')
|
||||
: state.authorable ? t('duplicate') : t('duplicateUnavailable')}
|
||||
aria-label={`${t('duplicate')}: ${text.name}`}
|
||||
onClick={() => { props.beginCopy(row.id) }}
|
||||
>
|
||||
<IconCopyOutline16 />
|
||||
</button>
|
||||
{row.trust === 'user'
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${css.iconButton} ${css.iconDanger}`}
|
||||
data-tip={t('delete')}
|
||||
aria-label={`${t('delete')}: ${text.name}`}
|
||||
onClick={() => { props.confirmDelete(row.id) }}
|
||||
>
|
||||
<IconTrashOutline16 />
|
||||
</button>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
{state.revealedPaths[row.id] === undefined
|
||||
? null
|
||||
: (
|
||||
<p className={css.revealedPath}>
|
||||
<span className={css.revealedPathLabel}>{t('revealedPathLabel')}</span>
|
||||
<code>{state.revealedPaths[row.id]}</code>
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{tail}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
{/* The guided alternative to copying: the self-referential preset can
|
||||
read this very composition and author a new one in conversation.
|
||||
Offered only where that preset is actually on the roster and a
|
||||
session can be landed; without a writable root the draft could
|
||||
never be discovered, so the reason rides the disabled button. */}
|
||||
{props.startCreatorDraft !== undefined && state.rows.some(row => row.id === 'cordis')
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.creatorButton}
|
||||
disabled={!state.authorable}
|
||||
title={state.authorable ? undefined : t('duplicateUnavailable')}
|
||||
onClick={() => {
|
||||
props.startCreatorDraft?.()
|
||||
props.close()
|
||||
}}
|
||||
>
|
||||
{/* Same glyph as the Models page's add affordances. */}
|
||||
<IconPlusOutline16 size={14} />
|
||||
{t('creatorDraft')}
|
||||
</button>
|
||||
)
|
||||
: null}
|
||||
<CopyDialog
|
||||
state={state}
|
||||
t={t}
|
||||
|
||||
@@ -114,6 +114,7 @@ export function apply(ctx: ClientContext): void {
|
||||
hooks: { agentPresetSeat: seat.store },
|
||||
load: () => seat.load(),
|
||||
select: (id: string) => seat.select(id),
|
||||
introduced: () => { seat.introduced() },
|
||||
})
|
||||
|
||||
const labelInjected = (): AgentPresetLabelInjected => ({
|
||||
@@ -146,7 +147,9 @@ export function apply(ctx: ClientContext): void {
|
||||
// on: the chip's list-change applier composes the blank session the
|
||||
// workspace connect produces or reuses.
|
||||
creatorDraft = () => {
|
||||
seat.stage('cordis')
|
||||
// The introduce cue makes the chip announce the pick the user never
|
||||
// made on this screen — the stage happened back in settings.
|
||||
seat.stage('cordis', true)
|
||||
scope.workspaces.startSession()
|
||||
}
|
||||
const chip = scope.slots.register({
|
||||
|
||||
@@ -26,10 +26,16 @@ export interface AgentPresetSeatState {
|
||||
/** A rejected apply's message, cleared by the next attempt. */
|
||||
error: string | null
|
||||
busy: boolean
|
||||
/**
|
||||
* One-shot cue that the chip should introduce itself (the creator-draft
|
||||
* entry staged the pick from another screen, so the user never touched the
|
||||
* chip); the renderer clears it via `introduced()` once played.
|
||||
*/
|
||||
introduce: boolean
|
||||
}
|
||||
|
||||
const INITIAL: AgentPresetSeatState = {
|
||||
options: [], current: '', error: null, busy: false,
|
||||
options: [], current: '', error: null, busy: false, introduce: false,
|
||||
}
|
||||
|
||||
/** One session's identity and whether it has started. */
|
||||
@@ -121,10 +127,18 @@ export class AgentPresetSeatController {
|
||||
* list-change applier, which fires when the started session becomes
|
||||
* current.
|
||||
* @param id - the preset to stage.
|
||||
* @param introduce - true when the stage came from another screen and the
|
||||
* chip should announce itself on the session it lands on.
|
||||
*/
|
||||
stage(id: string): void {
|
||||
stage(id: string, introduce = false): void {
|
||||
this.staged = id
|
||||
this.set({ current: id, error: null })
|
||||
this.set({ current: id, error: null, introduce })
|
||||
}
|
||||
|
||||
/** Acknowledge the introduction cue once the chip has played it. */
|
||||
introduced(): void {
|
||||
if (!this.store.getSnapshot().introduce) return
|
||||
this.set({ introduce: false })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -496,6 +496,15 @@ describe('ui-agent-preset apply', () => {
|
||||
expect(section.startCreatorDraft).toBeDefined()
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis')
|
||||
expect(workspaces.starts).toHaveLength(1)
|
||||
|
||||
// A cross-screen stage carries the introduce cue; the chip acknowledges
|
||||
// it once, and a repeat acknowledgement leaves the snapshot untouched.
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().introduce).toBe(true)
|
||||
seat.introduced()
|
||||
const acknowledged = seat.hooks.agentPresetSeat.getSnapshot()
|
||||
expect(acknowledged.introduce).toBe(false)
|
||||
seat.introduced()
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot()).toBe(acknowledged)
|
||||
conversation()
|
||||
})
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ const SEAT_READY: AgentPresetSeatState = {
|
||||
],
|
||||
busy: false,
|
||||
error: null,
|
||||
introduce: false,
|
||||
}
|
||||
|
||||
function renderRow(state: Partial<AgentPresetSettingsState> = {}) {
|
||||
@@ -56,7 +57,11 @@ function renderRow(state: Partial<AgentPresetSettingsState> = {}) {
|
||||
|
||||
function renderSeat(state: Partial<AgentPresetSeatState> = {}) {
|
||||
const store = createSnapshotStore<AgentPresetSeatState>({ ...SEAT_READY, ...state })
|
||||
const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(() => Promise.resolve()) }
|
||||
const actions = {
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
select: vi.fn(() => Promise.resolve()),
|
||||
introduced: vi.fn(),
|
||||
}
|
||||
render(<AgentPresetSeat {...({
|
||||
...actions,
|
||||
useAgentPresetSeat: bindSnapshotSelector(store),
|
||||
@@ -272,6 +277,94 @@ describe('the new-session chip', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('the chip introduce cue', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** Character spans carry inline animation delays; nothing else does. */
|
||||
function delayedChars(): HTMLElement[] {
|
||||
return Array.from(screen.getByRole('button').querySelectorAll<HTMLElement>('[style]'))
|
||||
}
|
||||
|
||||
it('reveals a long Latin name inside the shared window, then acknowledges', () => {
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false })))
|
||||
vi.useFakeTimers()
|
||||
const actions = renderSeat({
|
||||
current: 'creator',
|
||||
options: [{ id: 'creator', trust: 'user', name: 'CreatorMode' }],
|
||||
introduce: true,
|
||||
})
|
||||
|
||||
// Eleven characters split the 200ms window into 20ms steps, where the
|
||||
// fixed 40ms tick would have doubled the run for a Latin name.
|
||||
const chars = delayedChars()
|
||||
expect(chars.map(span => span.textContent).join('')).toBe('CreatorMode')
|
||||
expect(chars[0]!.style.animationDelay).toBe('150ms')
|
||||
expect(chars[1]!.style.animationDelay).toBe('170ms')
|
||||
expect(chars[10]!.style.animationDelay).toBe('350ms')
|
||||
|
||||
// 150 delay + 200 window + 400 fade: acknowledged only once the last
|
||||
// character has settled, and the label is plain text again after.
|
||||
act(() => { vi.advanceTimersByTime(749) })
|
||||
expect(actions.introduced).not.toHaveBeenCalled()
|
||||
act(() => { vi.advanceTimersByTime(1) })
|
||||
expect(actions.introduced).toHaveBeenCalledTimes(1)
|
||||
expect(delayedChars()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps the per-tick cap for a short CJK name', () => {
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false })))
|
||||
vi.useFakeTimers()
|
||||
renderSeat({
|
||||
current: 'creator',
|
||||
options: [{ id: 'creator', trust: 'user', name: '创造模式' }],
|
||||
introduce: true,
|
||||
})
|
||||
|
||||
// Four characters fit under the window, so the 40ms tick applies as-is.
|
||||
const chars = delayedChars()
|
||||
expect(chars).toHaveLength(4)
|
||||
expect(chars[1]!.style.animationDelay).toBe('190ms')
|
||||
expect(chars[3]!.style.animationDelay).toBe('270ms')
|
||||
})
|
||||
|
||||
it('starts a one-character name with no stagger at all', () => {
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false })))
|
||||
vi.useFakeTimers()
|
||||
const actions = renderSeat({
|
||||
current: 'creator',
|
||||
options: [{ id: 'creator', trust: 'user', name: 'C' }],
|
||||
introduce: true,
|
||||
})
|
||||
|
||||
expect(delayedChars()[0]!.style.animationDelay).toBe('150ms')
|
||||
act(() => { vi.advanceTimersByTime(550) })
|
||||
expect(actions.introduced).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('skips the run under reduced motion and acknowledges at once', () => {
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true })))
|
||||
const actions = renderSeat({ introduce: true })
|
||||
|
||||
expect(actions.introduced).toHaveBeenCalledTimes(1)
|
||||
expect(delayedChars()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('acknowledges an empty staged name without arming a run', () => {
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false })))
|
||||
const actions = renderSeat({
|
||||
current: 'creator',
|
||||
options: [{ id: 'creator', trust: 'user', name: '' }],
|
||||
introduce: true,
|
||||
})
|
||||
|
||||
expect(actions.introduced).toHaveBeenCalledTimes(1)
|
||||
expect(delayedChars()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the session-header label', () => {
|
||||
it('names the preset the session runs, and never offers a switch', async () => {
|
||||
const { load } = renderLabel({ blank: false, agentPreset: 'standard' })
|
||||
|
||||
@@ -253,6 +253,20 @@ describe('the preset list', () => {
|
||||
expect(actions.close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the empty custom group on screen: heading plus the creator entry', () => {
|
||||
renderSection({
|
||||
rows: [
|
||||
{ id: 'standard', trust: 'system', isDefault: true, name: '标准模式' },
|
||||
{ id: 'cordis', trust: 'system', isDefault: false, name: '创造模式' },
|
||||
],
|
||||
})
|
||||
|
||||
// No member yet, but the place where one's own preset will appear stays.
|
||||
expect(screen.getByRole('heading', { name: en.customGroup })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: en.creatorDraft })).toBeTruthy()
|
||||
expect(screen.queryByText(`· ${en.userTrust}`)).toBeNull()
|
||||
})
|
||||
|
||||
it('hides the creator entry without the flow or the preset, disables it without a root', () => {
|
||||
renderSection()
|
||||
expect(screen.queryByRole('button', { name: en.creatorDraft })).toBeNull()
|
||||
|
||||
@@ -60,7 +60,7 @@ export const zh = {
|
||||
'access.confirm.acknowledge': '我已了解风险,并愿意继续',
|
||||
'access.confirm.cancel': '取消',
|
||||
'access.confirm.enable': '启用 Full access',
|
||||
'hero.headline': '探索未知之境',
|
||||
'hero.headline': '探索未至之境',
|
||||
'hero.preview': '预览版',
|
||||
'hero.chooseWorkspace': '选择工作区',
|
||||
'session.hierarchy': '会话层级',
|
||||
|
||||
@@ -263,8 +263,9 @@
|
||||
.composerHero {
|
||||
position: relative; /* .heroGlow positioning context */
|
||||
align-self: center;
|
||||
/* figma 75:8208: 12 between hero chrome / workspace row / card. */
|
||||
gap: 12px;
|
||||
/* figma 75:8208 drew 12 between all three rows; the workspace row now sits
|
||||
8 above the card (its margin-top restores 12 under the hero chrome). */
|
||||
gap: 8px;
|
||||
/* Foot inside the centered box floats the stack a bit above true center. */
|
||||
padding-bottom: 32px;
|
||||
/* Card cap + both clearances: the hero input card lands at exactly the same
|
||||
@@ -292,7 +293,9 @@
|
||||
.heroWorkspaceRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
margin-top: 4px;
|
||||
/* figma drew px 8; nudged +12 so the chip's folder glyph lines up closer to
|
||||
the card's inner controls below. */
|
||||
padding-left: 20px;
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
border-radius: 16px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 13px;
|
||||
|
||||
@@ -51,6 +51,9 @@
|
||||
}
|
||||
|
||||
.chevron {
|
||||
/* inline-flex, not inline: an inline seat reserves baseline descent under
|
||||
the svg and floats the glyph off-center in the 28px trigger. */
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
transition: transform 120ms ease;
|
||||
|
||||
@@ -134,7 +134,7 @@ const compaction = (over: Partial<CompactionSummaryNode> = {}): CompactionSummar
|
||||
/** Empty sessions-list hook for the global standard-kit seat. */
|
||||
function emptySessions() {
|
||||
const store = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined })
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ afterEach(() => {
|
||||
|
||||
function emptySessions() {
|
||||
return bindSnapshotSelector(createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ describe('render branch tails', () => {
|
||||
const chat = createChatStore().create()
|
||||
chat.actions.select({ turnSeq: 1, callId: 'ghost' } satisfies SelectionTarget)
|
||||
const emptyList = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined })
|
||||
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
@@ -165,7 +165,7 @@ describe('render branch tails', () => {
|
||||
const chat = createChatStore().create()
|
||||
chat.actions.select({ turnSeq: 9, callId: 'p1:code:1:code:1', toolName: 'read' } satisfies SelectionTarget)
|
||||
const emptyList = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined })
|
||||
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
|
||||
@@ -136,7 +136,7 @@ function bench(over?: BenchOptions) {
|
||||
useSession: bindSnapshotSelector(session),
|
||||
useSessions: bindSnapshotSelector(createSnapshotStore({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
||||
})),
|
||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
|
||||
@@ -40,7 +40,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
||||
useSession: bindSnapshotSelector(session),
|
||||
useSessions: bindSnapshotSelector(createSnapshotStore({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
||||
})),
|
||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
|
||||
@@ -126,7 +126,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
useSession: bindSnapshotSelector(sessionStore),
|
||||
useSessions: bindSnapshotSelector(createSnapshotStore({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
||||
})),
|
||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
|
||||
@@ -110,7 +110,7 @@ function mount(
|
||||
ids: listed ? [root, SID] : [root],
|
||||
byId: { [root]: rootRow, ...listed && { [SID]: childRow } },
|
||||
current: SID,
|
||||
phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
|
||||
phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
||||
})
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>(workspaceState(workspaceRows))
|
||||
const session = createSnapshotStore<ConversationSnapshot>(snapshot)
|
||||
@@ -358,7 +358,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
const header = b.view.container.querySelector('header')
|
||||
expect(host).not.toBeNull()
|
||||
expect(header?.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(b.view.getByText('探索未知之境')).toBeTruthy()
|
||||
expect(b.view.getByText('探索未至之境')).toBeTruthy()
|
||||
expect(b.view.getByText('预览版')).toBeTruthy()
|
||||
expect(b.view.queryByTestId('view-chat')).toBeNull()
|
||||
// The same machine-backed textarea is live in the hero, and the
|
||||
@@ -382,7 +382,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }))
|
||||
const root = b.view.container.querySelector('[data-phase]')
|
||||
expect(root?.getAttribute('data-phase')).toBe('settling')
|
||||
expect(b.view.queryByText('探索未知之境')).toBeNull()
|
||||
expect(b.view.queryByText('探索未至之境')).toBeNull()
|
||||
})
|
||||
|
||||
it('settling phase: a session the list has no row for settles conservatively', () => {
|
||||
@@ -407,7 +407,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
// blank the column for the history round-trip.
|
||||
const root = b.view.container.querySelector('[data-phase]')
|
||||
expect(root?.getAttribute('data-phase')).toBe('hero')
|
||||
expect(b.view.getByText('探索未知之境')).toBeTruthy()
|
||||
expect(b.view.getByText('探索未至之境')).toBeTruthy()
|
||||
expect(b.view.getByRole('textbox')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -425,7 +425,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
expect(after.value).toBe('kept across flip')
|
||||
expect(b.chat.store.getSnapshot().draft).toBe('kept across flip')
|
||||
expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)
|
||||
expect(b.view.queryByText('探索未知之境')).toBeNull()
|
||||
expect(b.view.queryByText('探索未至之境')).toBeNull()
|
||||
expect(b.view.getByTestId('view-chat')).toBeTruthy()
|
||||
})
|
||||
|
||||
|
||||
@@ -349,6 +349,35 @@ export const IconThinkOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_ds_agent_preset_outline_16 (figma extract): node interiors knock out to transparency via mask, so the glyph sits on any fill. */
|
||||
export const IconAgentPresetOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_agent_preset_16" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16">
|
||||
<rect width="16" height="16" fill="white" />
|
||||
<circle cx="7.9995" cy="3.28319" r="1.712" fill="black" />
|
||||
<circle cx="3.51122" cy="11.3855" r="1.712" fill="black" />
|
||||
<circle cx="12.4878" cy="11.3855" r="1.712" fill="black" />
|
||||
</mask>
|
||||
<path
|
||||
mask="url(#mask0_agent_preset_16)"
|
||||
d="M12.2881 11.0425C12.6002 11.3723 13.0413 11.5786 13.5312 11.5786L13.5342 11.5776C13.1476 12.3233 12.6119 12.9785 11.9639 13.5005C10.9327 14.3309 9.6199 14.8286 8.19336 14.8286C7.29864 14.8285 6.45056 14.6313 5.6875 14.2808C6.08309 14.0281 6.36707 13.6189 6.45215 13.1392C6.99022 13.3561 7.57767 13.476 8.19336 13.4761C9.30019 13.4761 10.3157 13.0915 11.1152 12.4478C11.5935 12.0626 11.9924 11.5848 12.2881 11.0425ZM4.14746 4.36475C4.25569 4.83228 4.55488 5.2247 4.95898 5.4585C4.07956 6.30639 3.53144 7.49605 3.53125 8.81396C3.53125 9.69534 3.77613 10.5202 4.20117 11.2231C3.74959 11.3817 3.38395 11.7232 3.19531 12.1597C2.5541 11.2032 2.17969 10.052 2.17969 8.81396C2.17989 7.05087 2.93868 5.4646 4.14746 4.36475ZM8.19336 2.80029C8.85717 2.80029 9.49784 2.90834 10.0967 3.10791C12.3237 3.85044 13.9725 5.86061 14.1846 8.28369C13.9832 8.20048 13.7627 8.15382 13.5312 8.15381C13.2802 8.15381 13.042 8.20907 12.8271 8.30615C12.6281 6.47264 11.3666 4.95616 9.66895 4.39014C9.2063 4.236 8.70989 4.15186 8.19336 4.15186C7.96112 4.15189 7.7329 4.16981 7.50977 4.20264C7.51947 4.12886 7.52637 4.05348 7.52637 3.97705C7.52628 3.56604 7.3811 3.18914 7.13965 2.89404C7.48183 2.83352 7.83381 2.80033 8.19336 2.80029Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M9.1123 3.28271C9.11205 2.66858 8.61322 2.17041 7.99902 2.17041C7.38504 2.17067 6.88697 2.66874 6.88672 3.28271C6.88672 3.89691 7.38489 4.39574 7.99902 4.396C8.61338 4.396 9.1123 3.89707 9.1123 3.28271ZM10.3115 3.28271C10.3115 4.55981 9.27612 5.59521 7.99902 5.59521C6.72214 5.59496 5.6875 4.55965 5.6875 3.28271C5.68776 2.00599 6.7223 0.971447 7.99902 0.971191C9.27596 0.971191 10.3113 2.00584 10.3115 3.28271Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M4.62402 11.385C4.62377 10.7709 4.12494 10.2727 3.51074 10.2727C2.89676 10.273 2.39869 10.771 2.39844 11.385C2.39844 11.9992 2.89661 12.498 3.51074 12.4983C4.1251 12.4983 4.62402 11.9994 4.62402 11.385ZM5.82324 11.385C5.82324 12.6621 4.78784 13.6975 3.51074 13.6975C2.23386 13.6973 1.19922 12.6619 1.19922 11.385C1.19947 10.1083 2.23402 9.07374 3.51074 9.07349C4.78768 9.07349 5.82299 10.1081 5.82324 11.385Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M13.6006 11.385C13.6003 10.7709 13.1015 10.2727 12.4873 10.2727C11.8733 10.273 11.3753 10.771 11.375 11.385C11.375 11.9992 11.8732 12.498 12.4873 12.4983C13.1017 12.4983 13.6006 11.9994 13.6006 11.385ZM14.7998 11.385C14.7998 12.6621 13.7644 13.6975 12.4873 13.6975C11.2104 13.6973 10.1758 12.6619 10.1758 11.385C10.176 10.1083 11.2106 9.07374 12.4873 9.07349C13.7642 9.07349 14.7995 10.1081 14.7998 11.385Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_ds_browse_outline_16 */
|
||||
export const IconBrowseOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
@@ -16,8 +16,8 @@ const icons = Object.fromEntries(
|
||||
const iconNames = Object.keys(icons)
|
||||
|
||||
describe('ic_ds_ icon set', () => {
|
||||
it('exports the full icon set (46 deepsuite + 18 figma extracts + three product glyphs outside those sets)', () => {
|
||||
expect(iconNames.length).toBe(67)
|
||||
it('exports the full icon set (46 deepsuite + 19 figma extracts + three product glyphs outside those sets)', () => {
|
||||
expect(iconNames.length).toBe(68)
|
||||
})
|
||||
|
||||
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {
|
||||
|
||||
@@ -205,11 +205,11 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Options area (figma Options 501:29983): pad (24,0,24,8), scrolls. */
|
||||
/* Options area (figma Options 501:29983): pad (24,0,24,24), scrolls. */
|
||||
.options {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0 24px 8px;
|
||||
padding: 0 24px 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconCloseOutline16, IconDataOutline16, IconSettingsOutline16, IconThinkOutline16,
|
||||
IconAgentPresetOutline16, IconCloseOutline16, IconDataOutline16, IconSettingsOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts'
|
||||
import css from './SettingsRoot.module.css'
|
||||
@@ -22,7 +22,7 @@ import css from './SettingsRoot.module.css'
|
||||
/** Nav glyph by section id; unknown ids fall back to the settings gear. */
|
||||
function navIcon(id: string) {
|
||||
if (id === 'models') return <IconDataOutline16 className={css.navIcon} size={16} />
|
||||
if (id === 'agent-presets') return <IconThinkOutline16 className={css.navIcon} size={16} />
|
||||
if (id === 'agent-presets') return <IconAgentPresetOutline16 className={css.navIcon} size={16} />
|
||||
return <IconSettingsOutline16 className={css.navIcon} size={16} />
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
gap: 8px;
|
||||
height: 60px;
|
||||
padding: 8px 0 8px 4px;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 8px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -157,8 +157,8 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* New Session: 38px capsule (figma 133:7634); collapsed it renders as the
|
||||
rail's plain icon control. */
|
||||
/* New Session: 38px bar, 12px radius (figma 133:7634 geometry, squared-off
|
||||
corners); collapsed it renders as the rail's plain icon control. */
|
||||
.newSession {
|
||||
flex: none;
|
||||
display: flex;
|
||||
@@ -170,7 +170,7 @@
|
||||
margin: 0 2px 20px; /* bottom: former headerBlock padBottom 12 + root gap 8 */
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 24px;
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-button-elevated-fill);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 14px;
|
||||
|
||||
@@ -54,7 +54,6 @@
|
||||
max-height: min(560px, calc(100vh - 140px));
|
||||
padding: 4px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-specific-menu);
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
|
||||
@@ -519,8 +519,14 @@ export function SubagentCatalogAction({
|
||||
observedCatalogs.current.clear()
|
||||
}, [])
|
||||
|
||||
// Visibility needs evidence of children (entries, summary-known descendants,
|
||||
// or a failed load worth retrying). A bare loading catalog is not evidence:
|
||||
// selecting any session schedules a refresh whose loading snapshot would
|
||||
// otherwise flash the action in and out on childless sessions.
|
||||
const visible = presentedCatalog !== undefined
|
||||
&& (presentedCatalog.state !== 'ready' || presentedCatalog.entries.length > 0)
|
||||
&& (presentedCatalog.state === 'error'
|
||||
|| presentedCatalog.entries.length > 0
|
||||
|| descendantCount > 0)
|
||||
useEffect(() => {
|
||||
if (visible || !open) return
|
||||
setOpen(false)
|
||||
|
||||
@@ -61,6 +61,7 @@ function props(
|
||||
},
|
||||
current: PARENT, phase: 'ready',
|
||||
subagentsByParent: value === undefined ? nested : { [PARENT]: value, ...nested },
|
||||
tasksBySession: {},
|
||||
currentAddress: undefined,
|
||||
} satisfies SessionListState
|
||||
function useSessions<T>(select: (snapshot: SessionListState) => T): T {
|
||||
@@ -522,22 +523,23 @@ describe('SubagentCatalogAction', () => {
|
||||
expect(staleEmpty.openChild).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders empty loading and fallback error states without focusable rows', async () => {
|
||||
it('hides a bare loading catalog and keeps the error fallback without focusable rows', async () => {
|
||||
// Selecting any session schedules a catalog refresh; a loading snapshot
|
||||
// with no other evidence of children must not flash the action in.
|
||||
const loading = props(catalog({ entries: [], state: 'loading' }))
|
||||
const view = render(<SubagentCatalogAction {...loading} />)
|
||||
const trigger = screen.getByRole('button', { name: /0 个子代理/ })
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getByText('正在加载子代理…')).toBeTruthy()
|
||||
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
|
||||
await Promise.resolve()
|
||||
expect(screen.getByRole('tree')).toBeTruthy()
|
||||
fireEvent.keyDown(screen.getByRole('tree'), { key: 'ArrowUp' })
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
view.unmount()
|
||||
|
||||
const failed = props(catalog({ entries: [], state: 'error', error: null }))
|
||||
render(<SubagentCatalogAction {...failed} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /0 个子代理/ }))
|
||||
const trigger = screen.getByRole('button', { name: /0 个子代理/ })
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getByText('无法加载子代理')).toBeTruthy()
|
||||
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
|
||||
await Promise.resolve()
|
||||
expect(screen.getByRole('tree')).toBeTruthy()
|
||||
fireEvent.keyDown(screen.getByRole('tree'), { key: 'ArrowUp' })
|
||||
})
|
||||
|
||||
it('navigates from outside the tree and tolerates a deferred focus after unmount', async () => {
|
||||
|
||||
@@ -1,6 +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 packages/experimental/README.md
|
||||
README.md: db39af8bb1b1bcfd257e16e4ad1dd112f604ffb1
|
||||
README.zh.md: fc5942190a354668164b41b99e83b52b14d88f18
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-task/README.md
|
||||
README.md: a1430db55c7519c612e5d39de4627c750976d2d5
|
||||
README.zh.md: 5e29c939806f9a6500e78324c5a3322b6fd94539
|
||||
24
packages/client/ui-task/README.md
Normal file
24
packages/client/ui-task/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# @deepseek-ai/dsh-client-ui-task
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Web background-task feature owner: contributes one entry to `conversation.session.header.actions` listing the `ctx.tasks` records this session can see. The data arrives entirely through the `tasksBySession` list mirror that [`dsh-client-runtime`](../runtime/README.md) folds from `session/tasks` frames, so this package issues no RPC and holds no state beyond popover visibility.
|
||||
|
||||
The trigger renders only when the session has at least one task, so an ordinary conversation never grows a control for a capability it is not using. Its badge counts `running` plus `stopping` and is omitted at zero, leaving a session that holds only finished tasks a quiet entry point into its history rather than one advertising a count of nothing. The popover is a flat list: live rows first by `startedAt` ascending, then settled rows by `finishedAt` descending, with a same-millisecond tie broken on start order so the host's map iteration never decides it. A row shows the producer kind, the label, a status marker, the producer's `detail` in place of the generic status word once it has one, and an elapsed duration. That duration advances once per second while the row is live and freezes at `finishedAt`; the clock runs only while an open list holds something that moves. A settled row missing `finishedAt` reads as zero rather than as a negative figure, and a duration past an hour stays in hours rather than growing a day vocabulary no producer currently reaches.
|
||||
|
||||
Settled rows stay visible and de-emphasized until the registry drops them at owner disposal. They are in the snapshot, a failed task's `detail` is the only place its failure is legible, and filtering them out here is work the output and cancellation phases would undo. A running one-shot background subagent therefore appears both here and in the [subagent catalog](../ui-subagent/README.md): the catalog navigates into the child's transcript, while this list is the only handle a future cancellation can attach to.
|
||||
|
||||
Escape closes the list and returns focus to the trigger, as does a pointer press outside it. The last task disappearing closes the list before the control unmounts, so focus never vanishes from a removed node. Styling uses tokens only; copy goes through the package's own `task` locale namespace. The behavior is specified by the [Web background-task display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-task-display.md).
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this package renders host-computed registry state for a human and touches no prompt, message, schema, stream, or tool result. The model's own view of the same tasks stays with [`dsh-tool-tasks`](../../tasks/tool-tasks/README.md).
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; the package never assembles or sends provider requests.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Rows are read-only** — a task's streamed output and a human-initiated cancellation are separate phases. Cancellation additionally owes a model-facing decision the seam does not answer today: `kill()` marks terminal delivery reported, so an interrupt written against the current contract would leave the model believing its task is still running.
|
||||
- **The list is not the registry's own set** — it shows what one session can see through the wire view, so a task owned by another session never appears here, and a process restart empties the list while the transcript keeps the `run_in_background` cards that started those tasks. An unowned task (one started without a live `Agent`) is the opposite case: it reaches every session's list, matching what `list(caller)` reports to every caller.
|
||||
24
packages/client/ui-task/README.zh.md
Normal file
24
packages/client/ui-task/README.zh.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# @deepseek-ai/dsh-client-ui-task
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Web 后台任务特性的归属方:向 `conversation.session.header.actions` 贡献一个条目,列出当前会话可见的 `ctx.tasks` 记录。数据完全来自 [`dsh-client-runtime`](../runtime/README.md) 从 `session/tasks` 帧折叠出的 `tasksBySession` 列表镜像,因此本包不发任何 RPC,除弹层开合外不持有任何状态。
|
||||
|
||||
只有当会话至少有一个任务时才渲染触发器,普通对话不会因为一项未被使用的能力而长出控件。角标计数为 `running` 加 `stopping`,为零时省略,这样只剩已完成任务的会话保留一个安静的历史入口,而不是宣告一个「零」。弹层是一个扁平列表:活跃行在前按 `startedAt` 升序,随后终态行按 `finishedAt` 降序;毫秒相同的并列按启动顺序打破,宿主的 map 迭代顺序永远不参与决定。一行显示生产者 kind、label、状态标记、生产者一旦给出 `detail` 就取代通用状态词的那段文字,以及已耗时。该耗时在活跃时每秒推进,并在 `finishedAt` 冻结;只有当打开的列表里确实有会动的东西时时钟才运行。缺少 `finishedAt` 的终态行读作零而不是负数,超过一小时的耗时停留在小时单位,不会长出任何生产者目前都到不了的「天」词汇。
|
||||
|
||||
终态行保持可见并弱化,直到注册表在 owner 销毁时把它们丢掉。它们本就在快照里,失败任务的 `detail` 是其失败唯一可读之处,在这里过滤掉它们是输出与中断两期要推翻的工作。因此一个运行中的一次性后台 subagent 会同时出现在这里和 [subagent 目录](../ui-subagent/README.md)里:目录负责进入子会话的 transcript,而这个列表是将来中断能力唯一可能附着的句柄。
|
||||
|
||||
Escape 关闭列表并把焦点交还触发器,在其外部按下指针同理。最后一个任务消失时先关闭列表再卸载控件,焦点因此不会从一个被移除的节点上凭空消失。样式只用 token;文案走本包自己的 `task` locale 命名空间。行为由 [Web 后台任务展示 Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-task-display.md) 规定。
|
||||
|
||||
## Model Experience
|
||||
|
||||
无,因为本包为人类渲染宿主计算出的注册表状态,不触及 prompt、消息、schema、流或工具结果。模型对同一批任务的视角仍属于 [`dsh-tool-tasks`](../../tasks/tool-tasks/README.md)。
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
无;本包从不组装或发送 provider 请求。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **行是只读的** —— 任务的流式输出与人类发起的中断是各自独立的阶段。中断还额外欠一个 seam 目前没有回答的、面向模型的决策:`kill()` 会把终态投递标为已上报,所以照当前契约写出来的中断会让模型一直以为它的任务还在跑。
|
||||
- **列表不等于注册表自己的集合** —— 它展示的是「一个会话通过线路视图能看到什么」,所以别的会话拥有的任务在这里永远不出现;而进程重启会清空列表,transcript 里启动这些任务的 `run_in_background` 卡片却还在。无主任务(在没有活体 `Agent` 时启动的)是反过来的情形:它会进入每一个会话的列表,与 `list(caller)` 对每个调用方的报告一致。
|
||||
77
packages/client/ui-task/package.json
Normal file
77
packages/client/ui-task/package.json
Normal file
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-task",
|
||||
"description": "Session-header background-task list: live registry state mirrored from session/tasks frames",
|
||||
"version": "0.0.1-rc.1",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-primitives"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/client/ui-task"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
125
packages/client/ui-task/src/client/TaskListAction.module.css
Normal file
125
packages/client/ui-task/src/client/TaskListAction.module.css
Normal file
@@ -0,0 +1,125 @@
|
||||
.root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
min-height: 28px;
|
||||
padding: 3px 2px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.trigger:hover,
|
||||
.trigger:focus-visible {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.trigger svg {
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.triggerOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.triggerDot {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.count {
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 5px);
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
width: 336px;
|
||||
max-width: min(400px, calc(100vw - 32px));
|
||||
max-height: min(420px, calc(100vh - 140px));
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
overflow: auto;
|
||||
list-style: none;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-specific-menu);
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.rowSettled {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.rowDot {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.kind {
|
||||
flex: none;
|
||||
padding: 0 6px;
|
||||
border-radius: 5px;
|
||||
background: var(--dsw-alias-fill-l2);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-family: var(--dsw-font-mono);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.status,
|
||||
.duration {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
/* A failed task's detail is the producer's raw error text, so it has no bound;
|
||||
without this it widens the row past the menu instead of eliding like .label. */
|
||||
.status {
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.duration {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
192
packages/client/ui-task/src/client/TaskListAction.tsx
Normal file
192
packages/client/ui-task/src/client/TaskListAction.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
||||
import type { TaskView } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { IconChevronDownOutline14, StateDot, type StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { NS } from './locales.ts'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import css from './TaskListAction.module.css'
|
||||
|
||||
/** Full props for the session-header background-task action. */
|
||||
export type TaskListActionProps =
|
||||
PropsRuntime<'conversation.session.header.actions'> & PropsLocale<typeof NS>
|
||||
|
||||
/** Stable empty list so a session with no tasks keeps one array identity. */
|
||||
const NO_TASKS: readonly TaskView[] = []
|
||||
|
||||
/** A task the registry still holds open, and whose duration therefore ticks. */
|
||||
function isLive(task: TaskView): boolean {
|
||||
return task.status === 'running' || task.status === 'stopping'
|
||||
}
|
||||
|
||||
/** Closed-union exhaustiveness fence for the wire status set. */
|
||||
/* v8 ignore next 3 -- closed-union backstop; only reached if a status is forged */
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`unhandled task status: ${JSON.stringify(value)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Status marker semantics. `stopping` and `killed` share the attention color:
|
||||
* both mean the work ended (or is ending) on request rather than on its own.
|
||||
*/
|
||||
function dotState(status: TaskView['status']): StateDotState {
|
||||
switch (status) {
|
||||
case 'running': return 'ongoing'
|
||||
case 'stopping': return 'warning'
|
||||
case 'completed': return 'done'
|
||||
case 'killed': return 'warning'
|
||||
case 'failed': return 'error'
|
||||
/* v8 ignore next -- closed wire status union */
|
||||
default: return assertNever(status)
|
||||
}
|
||||
}
|
||||
|
||||
/** Human status word for the row and its accessible name. */
|
||||
function statusLabel(status: TaskView['status'], t: TranslateNS<typeof NS>): string {
|
||||
switch (status) {
|
||||
case 'running': return t('status.running')
|
||||
case 'stopping': return t('status.stopping')
|
||||
case 'completed': return t('status.completed')
|
||||
case 'killed': return t('status.killed')
|
||||
case 'failed': return t('status.failed')
|
||||
/* v8 ignore next -- closed wire status union */
|
||||
default: return assertNever(status)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Elapsed time in at most two adjacent units. A background task that outlives
|
||||
* an hour is already exceptional, so hours is the widest unit — beyond that the
|
||||
* figure stays in hours rather than growing a day/month vocabulary no producer
|
||||
* currently reaches.
|
||||
*/
|
||||
function formatDuration(elapsedMs: number, t: TranslateNS<typeof NS>): string {
|
||||
const total = Math.max(0, Math.floor(elapsedMs / 1_000))
|
||||
const seconds = total % 60
|
||||
const minutes = Math.floor(total / 60) % 60
|
||||
const hours = Math.floor(total / 3_600)
|
||||
if (hours > 0) return t('duration.hours', { hours, minutes })
|
||||
if (minutes > 0) return t('duration.minutes', { minutes, seconds })
|
||||
return t('duration.seconds', { seconds })
|
||||
}
|
||||
|
||||
/**
|
||||
* Live rows first in start order, then settled rows newest-first. Two tasks
|
||||
* that settled in the same millisecond fall back to start order, so the sort
|
||||
* never depends on the host's map iteration.
|
||||
*/
|
||||
function ordered(tasks: readonly TaskView[]): TaskView[] {
|
||||
return [...tasks].sort((left, right) => {
|
||||
const liveLeft = isLive(left)
|
||||
if (liveLeft !== isLive(right)) return liveLeft ? -1 : 1
|
||||
if (liveLeft) return left.startedAt - right.startedAt
|
||||
const finished = (right.finishedAt ?? right.startedAt) - (left.finishedAt ?? left.startedAt)
|
||||
return finished !== 0 ? finished : left.startedAt - right.startedAt
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Session-header entry point for this session's background tasks. It renders
|
||||
* nothing at all until the session has at least one task, so an ordinary
|
||||
* conversation never grows a control for a capability it is not using.
|
||||
* @param props - runtime slot currency plus the namespace translator.
|
||||
* @returns the trigger and its popover list, or null when there is nothing to show.
|
||||
*/
|
||||
export function TaskListAction({ sessionId, useSessions, t }: TaskListActionProps) {
|
||||
const tasks = useSessions(state => state.tasksBySession[sessionId]) ?? NO_TASKS
|
||||
const [open, setOpen] = useState(false)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const rows = useMemo(() => ordered(tasks), [tasks])
|
||||
const liveCount = useMemo(() => tasks.filter(isLive).length, [tasks])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const closeOutside = (event: PointerEvent): void => {
|
||||
if (event.target instanceof Node && !rootRef.current?.contains(event.target)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('pointerdown', closeOutside)
|
||||
return () => { document.removeEventListener('pointerdown', closeOutside) }
|
||||
}, [open])
|
||||
|
||||
// The clock only runs while an open list is showing something that moves.
|
||||
useEffect(() => {
|
||||
if (!open || liveCount === 0) return
|
||||
setNow(Date.now())
|
||||
const timer = setInterval(() => { setNow(Date.now()) }, 1_000)
|
||||
return () => { clearInterval(timer) }
|
||||
}, [open, liveCount])
|
||||
|
||||
// The last task disappearing removes this control; close first so focus does
|
||||
// not vanish from an unmounting node.
|
||||
useEffect(() => {
|
||||
if (tasks.length === 0 && open) setOpen(false)
|
||||
}, [tasks.length, open])
|
||||
|
||||
if (tasks.length === 0) return null
|
||||
|
||||
const countKey = liveCount > 0
|
||||
? (liveCount === 1 ? 'count.live.one' : 'count.live.other')
|
||||
: (tasks.length === 1 ? 'count.idle.one' : 'count.idle.other')
|
||||
const countLabel = t(countKey, { count: liveCount > 0 ? liveCount : tasks.length })
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
if (event.key !== 'Escape' || !open) return
|
||||
event.preventDefault()
|
||||
setOpen(false)
|
||||
triggerRef.current?.focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={css.root} onKeyDown={onKeyDown}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={css.trigger}
|
||||
aria-expanded={open}
|
||||
aria-label={countLabel}
|
||||
onClick={() => {
|
||||
// Sample the clock in the same commit that opens the list: the
|
||||
// mount-time value predates every task, so the first painted frame
|
||||
// would otherwise clamp a long-running row to zero until the
|
||||
// open effect corrects it a frame later.
|
||||
setNow(Date.now())
|
||||
setOpen(current => !current)
|
||||
}}
|
||||
>
|
||||
{liveCount > 0 ? <StateDot state="ongoing" className={css.triggerDot} /> : null}
|
||||
<span className={css.count}>{countLabel}</span>
|
||||
<IconChevronDownOutline14 className={open ? css.triggerOpen : undefined} />
|
||||
</button>
|
||||
{open
|
||||
? (
|
||||
<ul className={css.menu} aria-label={t('list.aria')}>
|
||||
{rows.map((task) => {
|
||||
const live = isLive(task)
|
||||
const elapsed = live ? now - task.startedAt : (task.finishedAt ?? task.startedAt) - task.startedAt
|
||||
const duration = formatDuration(elapsed, t)
|
||||
const status = statusLabel(task.status, t)
|
||||
return (
|
||||
<li key={task.id} className={live ? css.row : `${css.row} ${css.rowSettled}`}>
|
||||
<StateDot state={dotState(task.status)} className={css.rowDot} />
|
||||
<span className={css.kind}>{task.kind}</span>
|
||||
<span className={css.label} title={task.label}>{task.label}</span>
|
||||
<span className={css.status} title={task.detail ?? status}>{task.detail ?? status}</span>
|
||||
<span
|
||||
className={css.duration}
|
||||
title={t(live ? 'duration.title.live' : 'duration.title.done', { duration })}
|
||||
>
|
||||
{duration}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
40
packages/client/ui-task/src/client/index.ts
Normal file
40
packages/client/ui-task/src/client/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Background-task plugin, browser half: contributes one session-header action
|
||||
* that renders this session's `ctx.tasks` records. The data arrives entirely
|
||||
* through the `tasksBySession` list mirror, so the plugin issues no RPC and
|
||||
* holds no state of its own beyond popover visibility.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { TaskListAction } from './TaskListAction.tsx'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { en, NS, zh, type TaskKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** Background-task list copy. */
|
||||
'task': TaskKey
|
||||
}
|
||||
}
|
||||
|
||||
export type { TaskListActionProps } from './TaskListAction.tsx'
|
||||
|
||||
/** Required services for locale registration and header-slot contribution. */
|
||||
export const inject = ['sessions', 'slots', 'locale']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the dictionaries and the header action.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-task: dictionaries')
|
||||
ctx.slots.inject(
|
||||
'conversation.session.header.actions',
|
||||
() => ctx.slots.register({
|
||||
name: 'conversation.session.header.actions',
|
||||
id: 'task-list',
|
||||
// After the subagent catalog: session lineage reads before process work.
|
||||
order: 20,
|
||||
locale: NS,
|
||||
}, TaskListAction),
|
||||
)
|
||||
}
|
||||
45
packages/client/ui-task/src/client/locales.ts
Normal file
45
packages/client/ui-task/src/client/locales.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/** `task` namespace dictionaries. */
|
||||
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
export const NS = 'task'
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'count.live.one': '{count} 个后台任务运行中',
|
||||
'count.live.other': '{count} 个后台任务运行中',
|
||||
'count.idle.one': '{count} 个后台任务',
|
||||
'count.idle.other': '{count} 个后台任务',
|
||||
'list.aria': '后台任务',
|
||||
'status.running': '运行中',
|
||||
'status.stopping': '正在停止',
|
||||
'status.completed': '已完成',
|
||||
'status.killed': '已取消',
|
||||
'status.failed': '已失败',
|
||||
'duration.seconds': '{seconds}秒',
|
||||
'duration.minutes': '{minutes}分{seconds}秒',
|
||||
'duration.hours': '{hours}小时{minutes}分',
|
||||
'duration.title.live': '已运行 {duration}',
|
||||
'duration.title.done': '耗时 {duration}',
|
||||
} as const
|
||||
|
||||
/** English dictionary, key-identical to the Chinese source of truth. */
|
||||
export const en: Record<TaskKey, string> = {
|
||||
'count.live.one': '{count} background task running',
|
||||
'count.live.other': '{count} background tasks running',
|
||||
'count.idle.one': '{count} background task',
|
||||
'count.idle.other': '{count} background tasks',
|
||||
'list.aria': 'Background tasks',
|
||||
'status.running': 'running',
|
||||
'status.stopping': 'stopping',
|
||||
'status.completed': 'completed',
|
||||
'status.killed': 'cancelled',
|
||||
'status.failed': 'failed',
|
||||
'duration.seconds': '{seconds}s',
|
||||
'duration.minutes': '{minutes}m {seconds}s',
|
||||
'duration.hours': '{hours}h {minutes}m',
|
||||
'duration.title.live': 'Running for {duration}',
|
||||
'duration.title.done': 'Took {duration}',
|
||||
}
|
||||
|
||||
/** Key domain of the `task` namespace (zh is the source of truth). */
|
||||
export type TaskKey = keyof typeof zh
|
||||
6
packages/client/ui-task/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-task/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
9
packages/client/ui-task/src/index.ts
Normal file
9
packages/client/ui-task/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Background-task list plugin, node half. Pure UI plugin: the empty apply
|
||||
* exists so the plugin appears in the host cordis.yml / Loader; the browser
|
||||
* half ships via exports["./client"], discovered through the package.json
|
||||
* dshClient declaration.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this source plugin. */
|
||||
export function apply(): void {}
|
||||
32
packages/client/ui-task/src/invariant.ts
Normal file
32
packages/client/ui-task/src/invariant.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-task`.
|
||||
* @module @deepseek-ai/dsh-client-ui-task/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-task'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-task-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package is a read-only projection of the
|
||||
* `tasksBySession` mirror onto one header slot entry. It emits no cordis
|
||||
* events, owns no cross-plugin mutable state, and its single slot registration
|
||||
* proves disposal through the HMR-safety spec.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
91
packages/client/ui-task/tests/browser-plugin.spec.ts
Normal file
91
packages/client/ui-task/tests/browser-plugin.spec.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* ui-task plugin halves: the browser entry's dictionary and header-slot
|
||||
* registrations against the real SlotsService (with fiber teardown proving
|
||||
* removal — HMR safety), the inert node entry, and the invariant companion's
|
||||
* ownership reservation.
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { apply as applyNode } from '../src/index.ts'
|
||||
import * as TaskInvariant from '../src/invariant.ts'
|
||||
import { en, NS, zh } from '../src/client/locales.ts'
|
||||
|
||||
/** Slot ledger reader: entry ids currently registered in the header list. */
|
||||
function headerEntryIds(ctx: Context): (string | undefined)[] {
|
||||
return ctx.slots
|
||||
.entries('conversation.session.header.actions')
|
||||
.map(entry => entry.options.id)
|
||||
}
|
||||
|
||||
/** Boot the browser half over a real slot tree that declares the header list. */
|
||||
async function bench(): Promise<{ ctx: Context; fiber: ReturnType<Context['plugin']> }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
ctx.slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
|
||||
},
|
||||
} as never, () => null)
|
||||
ctx.provide('sessions', {})
|
||||
// The locale plugin binds a settings scope, which reads the connection handle.
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
return { ctx, fiber }
|
||||
}
|
||||
|
||||
describe('ui-task browser half', () => {
|
||||
it('declares the services it binds', () => {
|
||||
expect(inject).toEqual(['sessions', 'slots', 'locale'])
|
||||
})
|
||||
|
||||
it('registers the header action, and fiber teardown removes it (HMR safety)', async () => {
|
||||
const { ctx, fiber } = await bench()
|
||||
expect(headerEntryIds(ctx)).toContain('task-list')
|
||||
await fiber.dispose()
|
||||
expect(headerEntryIds(ctx)).not.toContain('task-list')
|
||||
})
|
||||
|
||||
it('registers both dictionaries under its own namespace and releases them with the fiber', async () => {
|
||||
const { ctx, fiber } = await bench()
|
||||
const translate = ctx.locale.bind(NS)
|
||||
expect(translate('list.aria')).toBe(zh['list.aria'])
|
||||
ctx.locale.setLocale('en')
|
||||
expect(translate('list.aria')).toBe(en['list.aria'])
|
||||
|
||||
// Withdrawn dictionaries leave the key unresolved rather than translated.
|
||||
await fiber.dispose()
|
||||
expect(translate('list.aria')).not.toBe(en['list.aria'])
|
||||
})
|
||||
|
||||
it('keeps the English dictionary key-identical to the Chinese source of truth', () => {
|
||||
expect(Object.keys(en).sort()).toEqual(Object.keys(zh).sort())
|
||||
})
|
||||
})
|
||||
|
||||
describe('ui-task node half', () => {
|
||||
it('contributes no host behavior', () => {
|
||||
// The node half exists only so the plugin appears in the Loader tree.
|
||||
expect(applyNode).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ui-task invariant companion', () => {
|
||||
it('reserves package ownership under its declared companion name', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
const fiber = ctx.plugin(TaskInvariant)
|
||||
await fiber.await()
|
||||
expect(TaskInvariant.name).toBe('client-ui-task-invariant')
|
||||
expect(TaskInvariant.inject).toEqual(['invariants'])
|
||||
// Emitting an unrelated event proves the companion installed no audit.
|
||||
expect(() => { (ctx.emit as (event: string) => void)('slots/changed') }).not.toThrow()
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
239
packages/client/ui-task/tests/task-list-action.spec.tsx
Normal file
239
packages/client/ui-task/tests/task-list-action.spec.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { SessionId, SessionListState, TaskView } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { TaskListAction, type TaskListActionProps } from '../src/client/TaskListAction.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
// Live rows render `now - startedAt`, so every assertion needs a pinned clock.
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(START)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const SESSION = 'session' as SessionId
|
||||
const START = 1_700_000_000_000
|
||||
const t: TaskListActionProps['t'] = makeTranslate(zh)
|
||||
|
||||
function task(over: Partial<TaskView> = {}): TaskView {
|
||||
return {
|
||||
id: 'bash-1' as TaskView['id'],
|
||||
kind: 'bash',
|
||||
label: 'pnpm run build',
|
||||
status: 'running',
|
||||
startedAt: START,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function props(tasks: readonly TaskView[] | undefined): TaskListActionProps {
|
||||
const state = {
|
||||
ids: [SESSION],
|
||||
byId: {},
|
||||
current: SESSION,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
tasksBySession: tasks === undefined ? {} : { [SESSION]: tasks },
|
||||
currentAddress: undefined,
|
||||
} satisfies SessionListState
|
||||
function useSessions<T>(select: (snapshot: SessionListState) => T): T {
|
||||
return select(state)
|
||||
}
|
||||
return { sessionId: SESSION, useSessions, t } as unknown as TaskListActionProps
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows in render order as `[kind, label, status, duration]`. Adjacent spans
|
||||
* carry no whitespace between them, so the cells are read one element at a
|
||||
* time rather than split out of a flattened string.
|
||||
*/
|
||||
function rowCells(): string[][] {
|
||||
return within(screen.getByRole('list', { name: zh['list.aria'] }))
|
||||
.getAllByRole('listitem')
|
||||
.map(row => [...row.children]
|
||||
.map(cell => cell.textContent ?? '')
|
||||
.filter(text => text !== ''))
|
||||
}
|
||||
|
||||
describe('TaskListAction visibility', () => {
|
||||
it('renders nothing while the session has no tasks', () => {
|
||||
const { container } = render(<TaskListAction {...props(undefined)} />)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('counts only live tasks, and falls back to the total when none are live', () => {
|
||||
const { rerender } = render(<TaskListAction {...props([task(), task({ id: 'bash-2' as TaskView['id'] })])} />)
|
||||
expect(screen.getByRole('button', { name: '2 个后台任务运行中' })).toBeDefined()
|
||||
|
||||
rerender(<TaskListAction {...props([task({ status: 'completed', finishedAt: START + 3_000 })])} />)
|
||||
expect(screen.getByRole('button', { name: '1 个后台任务' })).toBeDefined()
|
||||
})
|
||||
|
||||
it('closes and unmounts when the last task disappears while the list is open', () => {
|
||||
const { container, rerender } = render(<TaskListAction {...props([task()])} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(screen.getByRole('list', { name: zh['list.aria'] })).toBeDefined()
|
||||
|
||||
rerender(<TaskListAction {...props([])} />)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TaskListAction rows', () => {
|
||||
it('orders live tasks by start, then settled tasks newest-first', () => {
|
||||
render(<TaskListAction {...props([
|
||||
task({ id: 'bash-3' as TaskView['id'], label: 'old done', status: 'completed', startedAt: START, finishedAt: START + 1_000 }),
|
||||
task({ id: 'bash-4' as TaskView['id'], label: 'new done', status: 'failed', startedAt: START, finishedAt: START + 9_000 }),
|
||||
task({ id: 'bash-2' as TaskView['id'], label: 'later live', startedAt: START + 5_000 }),
|
||||
task({ id: 'bash-1' as TaskView['id'], label: 'earlier live', startedAt: START }),
|
||||
])} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(rowCells()).toEqual([
|
||||
['bash', 'earlier live', '运行中', '0秒'],
|
||||
['bash', 'later live', '运行中', '0秒'],
|
||||
['bash', 'new done', '已失败', '9秒'],
|
||||
['bash', 'old done', '已完成', '1秒'],
|
||||
])
|
||||
})
|
||||
|
||||
it('breaks a settled tie on start order so map iteration never decides it', () => {
|
||||
render(<TaskListAction {...props([
|
||||
task({ id: 'bash-2' as TaskView['id'], label: 'second', status: 'completed', startedAt: START + 10, finishedAt: START + 100 }),
|
||||
task({ id: 'bash-1' as TaskView['id'], label: 'first', status: 'completed', startedAt: START, finishedAt: START + 100 }),
|
||||
])} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(rowCells().map(cells => cells[1])).toEqual(['first', 'second'])
|
||||
})
|
||||
|
||||
it('prefers the producer detail over the generic status word', () => {
|
||||
render(<TaskListAction {...props([
|
||||
task({ status: 'killed', detail: 'signal: SIGTERM', finishedAt: START + 2_000 }),
|
||||
])} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(rowCells()[0]).toContain('signal: SIGTERM')
|
||||
})
|
||||
|
||||
it('renders every status word, including the stopping transition', () => {
|
||||
render(<TaskListAction {...props([
|
||||
task({ id: 'bash-1' as TaskView['id'], label: 'a', status: 'running' }),
|
||||
task({ id: 'bash-2' as TaskView['id'], label: 'b', status: 'stopping' }),
|
||||
task({ id: 'bash-3' as TaskView['id'], label: 'c', status: 'completed', finishedAt: START }),
|
||||
task({ id: 'bash-4' as TaskView['id'], label: 'd', status: 'killed', finishedAt: START }),
|
||||
task({ id: 'bash-5' as TaskView['id'], label: 'e', status: 'failed', finishedAt: START }),
|
||||
])} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
const words = rowCells().map(cells => cells[2])
|
||||
expect(new Set(words)).toEqual(new Set(['运行中', '正在停止', '已完成', '已取消', '已失败']))
|
||||
})
|
||||
})
|
||||
|
||||
describe('TaskListAction duration', () => {
|
||||
it('advances a live row once per second and freezes a settled one', () => {
|
||||
vi.setSystemTime(START + 1_000)
|
||||
render(<TaskListAction {...props([
|
||||
task({ id: 'bash-1' as TaskView['id'], label: 'live' }),
|
||||
task({ id: 'bash-2' as TaskView['id'], label: 'done', status: 'completed', finishedAt: START + 4_000 }),
|
||||
])} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(rowCells()[0]).toContain('1秒')
|
||||
expect(rowCells()[1]).toContain('4秒')
|
||||
|
||||
act(() => { vi.advanceTimersByTime(2_000) })
|
||||
expect(rowCells()[0]).toContain('3秒')
|
||||
expect(rowCells()[1]).toContain('4秒')
|
||||
})
|
||||
|
||||
it('widens to minutes and then hours, and never shows a negative figure', () => {
|
||||
render(<TaskListAction {...props([
|
||||
task({ id: 'bash-1' as TaskView['id'], label: 'm', status: 'completed', finishedAt: START + 125_000 }),
|
||||
task({ id: 'bash-2' as TaskView['id'], label: 'h', status: 'completed', finishedAt: START + 7_380_000 }),
|
||||
// A clock that moved backwards must not render a negative duration.
|
||||
task({ id: 'bash-3' as TaskView['id'], label: 'skew', status: 'completed', startedAt: START + 5_000, finishedAt: START }),
|
||||
])} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(rowCells().map(cells => cells[3])).toEqual(['2小时3分', '2分5秒', '0秒'])
|
||||
})
|
||||
|
||||
it('runs no clock while the list is closed', () => {
|
||||
const interval = vi.spyOn(globalThis, 'setInterval')
|
||||
render(<TaskListAction {...props([task()])} />)
|
||||
expect(interval).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(interval).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('runs no clock for an open list holding only settled tasks', () => {
|
||||
const interval = vi.spyOn(globalThis, 'setInterval')
|
||||
render(<TaskListAction {...props([task({ status: 'completed', finishedAt: START })])} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(interval).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TaskListAction dismissal', () => {
|
||||
it('closes on Escape and returns focus to the trigger', () => {
|
||||
render(<TaskListAction {...props([task()])} />)
|
||||
const trigger = screen.getByRole('button')
|
||||
fireEvent.click(trigger)
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('true')
|
||||
|
||||
fireEvent.keyDown(trigger, { key: 'Escape' })
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
})
|
||||
|
||||
it('ignores other keys and a closed-list Escape', () => {
|
||||
render(<TaskListAction {...props([task()])} />)
|
||||
const trigger = screen.getByRole('button')
|
||||
fireEvent.keyDown(trigger, { key: 'Escape' })
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('true')
|
||||
})
|
||||
|
||||
it('closes on an outside pointer press but not on one inside', () => {
|
||||
render(<TaskListAction {...props([task()])} />)
|
||||
const trigger = screen.getByRole('button')
|
||||
fireEvent.click(trigger)
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole('list', { name: zh['list.aria'] }))
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('true')
|
||||
|
||||
fireEvent.pointerDown(document.body)
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TaskListAction wire tolerance', () => {
|
||||
it('treats a settled task with no finishedAt as zero-duration and sorts it by start', () => {
|
||||
// `finishedAt` is optional on the wire; the Host always sets it, so this
|
||||
// covers a producer or carrier that ever stops doing so.
|
||||
render(<TaskListAction {...props([
|
||||
task({ id: 'bash-1' as TaskView['id'], label: 'no finish', status: 'completed' }),
|
||||
task({ id: 'bash-2' as TaskView['id'], label: 'finished', status: 'completed', startedAt: START - 1_000, finishedAt: START + 2_000 }),
|
||||
])} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(rowCells().map(cells => [cells[1], cells[3]])).toEqual([
|
||||
['finished', '3秒'],
|
||||
['no finish', '0秒'],
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to start order when neither settled task carries a finish time', () => {
|
||||
render(<TaskListAction {...props([
|
||||
task({ id: 'bash-2' as TaskView['id'], label: 'later', status: 'failed', startedAt: START + 1_000 }),
|
||||
task({ id: 'bash-1' as TaskView['id'], label: 'earlier', status: 'failed', startedAt: START }),
|
||||
])} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(rowCells().map(cells => cells[1])).toEqual(['later', 'earlier'])
|
||||
})
|
||||
})
|
||||
33
packages/client/ui-task/tsconfig.json
Normal file
33
packages/client/ui-task/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-conversation"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/ui-task/tsdown.config.ts
Normal file
3
packages/client/ui-task/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-task', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
@@ -22,7 +22,7 @@ const COPY: Record<string, string> = {
|
||||
/** Empty global standard-kit hooks (the row reads neither). */
|
||||
function emptySessions() {
|
||||
const store = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined })
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
function emptyWorkspaces() {
|
||||
|
||||
@@ -112,7 +112,7 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } },
|
||||
current: SID,
|
||||
phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
|
||||
phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
||||
})
|
||||
const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) }
|
||||
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
|
||||
@@ -30,7 +30,7 @@ function listStore() {
|
||||
},
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
subagentsByParent: {}, tasksBySession: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ describe('FileMutationRow diff card', () => {
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
subagentsByParent: {}, tasksBySession: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
|
||||
@@ -311,13 +311,13 @@ describe('DetailsPanel diff Output section', () => {
|
||||
const chat = createChatStore().create()
|
||||
if (selection !== null) chat.actions.select(selection)
|
||||
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }
|
||||
: {
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
subagentsByParent: {}, tasksBySession: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>({
|
||||
|
||||
@@ -173,7 +173,7 @@ describe('ReadRow keyed toolview', () => {
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
subagentsByParent: {}, tasksBySession: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
|
||||
@@ -257,13 +257,13 @@ describe('DetailsPanel Output section (read)', () => {
|
||||
const chat = createChatStore().create()
|
||||
if (selection !== null) chat.actions.select(selection)
|
||||
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }
|
||||
: {
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
subagentsByParent: {}, tasksBySession: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>({
|
||||
|
||||
@@ -380,7 +380,7 @@ describe('DetailsPanel Output section (search)', () => {
|
||||
if (selection !== null) chat.actions.select(selection)
|
||||
const sessions = createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
||||
})
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
|
||||
@@ -348,7 +348,7 @@ describe('BashRow terminal card', () => {
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
subagentsByParent: {}, tasksBySession: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
|
||||
@@ -448,13 +448,13 @@ describe('DetailsPanel Output section', () => {
|
||||
const chat = createChatStore().create()
|
||||
if (selection !== null) chat.actions.select(selection)
|
||||
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }
|
||||
: {
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
subagentsByParent: {}, tasksBySession: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>({
|
||||
@@ -649,7 +649,7 @@ describe('DetailsPanel Output section', () => {
|
||||
useSessions={bindSnapshotSelector(createSnapshotStore<SessionListState>(
|
||||
{
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
||||
}))}
|
||||
useWorkspaces={bindSnapshotSelector(createSnapshotStore<WorkspaceListState>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
|
||||
@@ -210,7 +210,7 @@ describe('DetailsPanel web Output section', () => {
|
||||
if (selection !== null) chat.actions.select(selection)
|
||||
const sessions = createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
||||
})
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
|
||||
@@ -140,7 +140,7 @@ function fakeSession(nodes: ConversationSnapshot['nodes']) {
|
||||
/** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
|
||||
function emptySessions() {
|
||||
const store = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined })
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,8 @@
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
/* Search input: 38px capsule (figma 133:7649); rail state renders it as the
|
||||
/* Search input: 38px bar, 12px radius (figma 133:7649 geometry, squared-off
|
||||
corners); rail state renders it as the
|
||||
region's search control. Upstream binds a dedicated design-system variable
|
||||
(light #F1F3F5 / dark #1B1B1C) matching no shipped alias — a component
|
||||
token pinned to the static scale mirrors it. */
|
||||
@@ -79,7 +80,7 @@
|
||||
padding: 0 14px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 24px;
|
||||
border-radius: 12px;
|
||||
background: var(--dsh-search-input-fill);
|
||||
color: var(--dsw-alias-label-caption);
|
||||
overflow: hidden;
|
||||
|
||||
@@ -17,7 +17,7 @@ const list = (...items: SessionSummary[]): SessionListState => ({
|
||||
ids: items.map(item => item.id),
|
||||
byId: Object.fromEntries(items.map(item => [item.id, item])),
|
||||
current: undefined,
|
||||
phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
|
||||
phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
||||
})
|
||||
const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({
|
||||
workspaceId: wid(id), path: `/projects/${id}`, title,
|
||||
|
||||
@@ -29,7 +29,7 @@ const sessionState = (items: readonly SessionSummary[], overrides: Partial<Sessi
|
||||
byId: Object.fromEntries(items.map(item => [item.id, item])),
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
subagentsByParent: {}, tasksBySession: {},
|
||||
currentAddress: undefined,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -28,7 +28,7 @@ function hook<T>(snapshot: T) {
|
||||
return function select<S>(selector: (state: T) => S): S { return selector(snapshot) }
|
||||
}
|
||||
const sessions: SessionListState = {
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
||||
}
|
||||
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
|
||||
items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
||||
|
||||
@@ -117,6 +117,10 @@ class RecordingFileSystem extends FileSystem {
|
||||
return this.entries.get(target.targetKey)?.content ?? ''
|
||||
}
|
||||
|
||||
override async readBytes(_target: FsTarget, _signal: AbortSignal | undefined, _maxBytes: number): Promise<Uint8Array> {
|
||||
throw new Error('not needed in workspace-context tests')
|
||||
}
|
||||
|
||||
override async streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
|
||||
if (signal !== undefined) this.signals.push(signal)
|
||||
signal?.throwIfAborted()
|
||||
|
||||
@@ -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/session/README.md
|
||||
README.md: db477d94037d3463870fc8e66ea35d5e607fb6fe
|
||||
README.zh.md: 1ce1e823a7e0fdbcf7b6898764a89c52b74adf6a
|
||||
README.md: 57569e9c0dbfa7cb696e3a561a9ff108c2ac981f
|
||||
README.zh.md: 16629dc70c79ca838ba7088aeafcc5b38b124f87
|
||||
|
||||
@@ -76,10 +76,11 @@ Also defines `TurnEndReasonMap`, the merge-extensible `kind`-tagged sum type for
|
||||
|
||||
An interrupted live turn ends with `{ kind: 'aborted', reason: AgentCancelCause }`, preserving the typed cancellation cause in the durable transcript. Persistence imports the coarse aborted outcome from the supported older format as `{ kind: 'aborted', reason: { kind: 'legacy' } }`, because that record did not retain its caller. A turn failure carries `{ kind: 'error', error }`; crash recovery alone synthesizes `{ kind: 'interrupted' }`.
|
||||
|
||||
Every `SessionEvent` carries two optional top-level fields (structural metadata):
|
||||
Every `SessionEvent` carries three optional top-level fields (structural metadata):
|
||||
|
||||
- `sourceEventSeqs?: number[]` — seq numbers of earlier events cited as sources (e.g., the `assistant/chunk` seqs behind an `assistant/message`, or the shadowed entries behind a compaction replacement entry). On `assistant/message`, a present `[]` records a known empty provider stream, while omission means a legacy or foreign event did not record the source stream; other surface events require a non-empty list when this field is present.
|
||||
- `surfaceOp?: SurfaceOp` — how this event entered the surface. Absent for non-surface events (boundaries, chunks, usage, errors).
|
||||
- `ignorable?: true` — marks an event a reader may safely skip when it does not recognize the type; absent means required, so an unknown-type event refuses session reconstruction ([mechanism](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)).
|
||||
|
||||
### Metadata types (`types.ts`)
|
||||
|
||||
@@ -139,5 +140,5 @@ Logging causes no invalidation, and exact reconstruction preserves request-prefi
|
||||
|
||||
- **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond boundary-based `fork()`.
|
||||
- **`fork()` cuts only at stable boundaries of live sessions** — the selected prefix must end outside an open turn and the source must be in the store; forking a persisted-but-unloaded session is excluded from the [fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md).
|
||||
- **`SESSION_FORMAT_VERSION` stays pinned at `0`** — pre-release, no broad compatibility implied: `Session` accepts only current seed shapes and a backend rejects any other version. Narrow storage import upgrades belong to the persistence boundary ([policy](../../../AGENTS.md), [pre-identity message recovery](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)).
|
||||
- **`SESSION_FORMAT_VERSION` stays pinned at `0`** — pre-release, no broad compatibility implied: `Session` accepts only current seed shapes, and a backend refuses any other version naming the direction (newer: "written by a newer harness — upgrade"; older: no upgrade path ships yet). Unknown event types refuse the same way unless marked `ignorable` in the envelope; the versioning mechanism is the [session-log-version-mechanism note](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md). Narrow storage import upgrades belong to the persistence boundary ([policy](../../../AGENTS.md), [pre-identity message recovery](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)).
|
||||
- **`TurnEndReasonMap` omits the ACP-named `refusal` / `max_turn_requests` variants** — producer-gated: they land when an adapter or the loop first emits them.
|
||||
|
||||
@@ -76,10 +76,11 @@
|
||||
|
||||
被中断的实时轮次以 `{ kind: 'aborted', reason: AgentCancelCause }` 结束,在持久 transcript(文本记录)中保留类型化取消原因。持久化会将受支持旧格式中的粗粒度中止结果导入为 `{ kind: 'aborted', reason: { kind: 'legacy' } }`,因为该记录没有保留调用方。轮次失败携带 `{ kind: 'error', error }`;只有崩溃恢复会合成 `{ kind: 'interrupted' }`。
|
||||
|
||||
每个 `SessionEvent` 都有两个可选顶层字段(结构元数据):
|
||||
每个 `SessionEvent` 都有三个可选顶层字段(结构元数据):
|
||||
|
||||
- `sourceEventSeqs?: number[]`:被引用为来源的较早事件 seq(例如 `assistant/message` 引用的 `assistant/chunk` seq,或压缩替换条目引用的已遮蔽条目)。对于 `assistant/message`,存在的 `[]` 表示已知提供方流为空;省略则表示旧版或外部事件没有记录源流。其他 surface 事件若有此字段,则要求非空列表。
|
||||
- `surfaceOp?: SurfaceOp`:事件进入 surface 的方式。非 surface 事件(边界、分片、用量、错误)不含该字段。
|
||||
- `ignorable?: true`:标记读取器在不认识事件类型时可以安全跳过该事件;缺失表示必需,不认识的事件类型会使会话重建被拒绝([机制](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md))。
|
||||
|
||||
### 元数据类型(`types.ts`)
|
||||
|
||||
@@ -139,5 +140,5 @@
|
||||
|
||||
- **会话分支/树**(pi 风格条目树):除非需要超越基于边界的 `fork()` 能力,否则暂缓。
|
||||
- **`fork()` 仅在实时会话的稳定边界处切分**:所选前缀结束时不得有开放轮次,且源会话必须位于存储中;[fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) 不支持对已持久化但未加载的会话进行 fork。
|
||||
- **`SESSION_FORMAT_VERSION` 固定为 `0`**:预发布阶段不承诺广泛兼容性;`Session` 只接受当前 seed 形状,后端会拒绝其他任何版本。范围受限的存储导入升级应由持久化边界负责([政策](../../../AGENTS.md)、[消息标识机制引入前的消息恢复](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md))。
|
||||
- **`SESSION_FORMAT_VERSION` 固定为 `0`**:预发布阶段不承诺广泛兼容性;`Session` 只接受当前 seed 形状,后端拒绝其他任何版本并说明方向(更新的版本提示"由更新的 harness 写入,请升级";更旧的版本说明尚无升级路径)。不认识的事件类型同样被拒绝,除非信封带 `ignorable` 标记;版本机制见 [session-log 版本机制 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)。范围受限的存储导入升级应由持久化边界负责([政策](../../../AGENTS.md)、[消息标识机制引入前的消息恢复](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md))。
|
||||
- **`TurnEndReasonMap` 不含 ACP(Agent Client Protocol)命名的 `refusal`/`max_turn_requests` 变体**:受生产方约束;只有当适配器或循环首次产生这些变体时才加入。
|
||||
|
||||
@@ -32,6 +32,7 @@ export type { ChunkRow, StorageRecord } from './chunk-rows.ts'
|
||||
export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts'
|
||||
export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
|
||||
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
|
||||
export { KNOWN_SESSION_EVENT_TYPES } from './known-event-types.ts'
|
||||
|
||||
/**
|
||||
* Find the latest closed turn that entered at least one model step, ignoring
|
||||
@@ -243,6 +244,7 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, index: numbe
|
||||
case 'data':
|
||||
case 'surfaceOp':
|
||||
case 'sourceEventSeqs':
|
||||
case 'ignorable':
|
||||
break
|
||||
default:
|
||||
throw new Error(`seed event at index ${index} has an invalid event envelope`)
|
||||
@@ -254,7 +256,8 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, index: numbe
|
||||
if (typeof type !== 'string'
|
||||
|| typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0
|
||||
|| typeof time !== 'number' || !Number.isSafeInteger(time)
|
||||
|| event['data'] === undefined) {
|
||||
|| event['data'] === undefined
|
||||
|| (event['ignorable'] !== undefined && event['ignorable'] !== true)) {
|
||||
throw new Error(`seed event at index ${index} has an invalid event envelope`)
|
||||
}
|
||||
switch (type) {
|
||||
|
||||
59
packages/core/session/src/known-event-types.ts
Normal file
59
packages/core/session/src/known-event-types.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* GENERATED by `scripts/gen-persistence-catalog.ts` — do not edit by hand; run
|
||||
* `pnpm run gen-persistence-catalog` to regenerate (verified fresh by
|
||||
* `pnpm run verify-persistence-catalog`, part of `doc-sync`).
|
||||
* @module @deepseek-ai/dsh-session/known-event-types
|
||||
*/
|
||||
|
||||
/**
|
||||
* Every `SessionEventMap` member declared in this repository — the event
|
||||
* vocabulary this build understands. The persistence read path refuses to
|
||||
* interpret a log containing a type outside this set unless the event
|
||||
* carries the envelope's `ignorable` marker (see `SessionEvent.ignorable`
|
||||
* in `./types.ts`): such a log was likely written by a newer harness, and
|
||||
* silently skipping a required event would reconstruct a wrong session.
|
||||
* Downstream (out-of-repo) plugin events are outside this list by
|
||||
* construction; a registration surface for them is deferred until such a
|
||||
* consumer exists.
|
||||
*/
|
||||
export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([
|
||||
'agent-preset/selected',
|
||||
'agent/inbox/spliced',
|
||||
'approval/asked',
|
||||
'approval/decided',
|
||||
'approval/policy',
|
||||
'assistant/chunk',
|
||||
'assistant/message',
|
||||
'command/done',
|
||||
'command/run',
|
||||
'compact/end',
|
||||
'compact/prune',
|
||||
'compact/start',
|
||||
'compact/summary',
|
||||
'feedback/record',
|
||||
'goal/change',
|
||||
'hook/invoked',
|
||||
'hook/result',
|
||||
'llm/retry',
|
||||
'llm/retry-started',
|
||||
'permission/preset',
|
||||
'plan/mode',
|
||||
'request/context',
|
||||
'request/header',
|
||||
'sandbox/mode',
|
||||
'session/end-seed',
|
||||
'session/title',
|
||||
'session/title-llm-request',
|
||||
'step/end',
|
||||
'step/start',
|
||||
'subagent/descriptor',
|
||||
'todo/write',
|
||||
'tool/call',
|
||||
'tool/code-dispatch',
|
||||
'tool/code-dispatch-start',
|
||||
'tool/result',
|
||||
'turn/end',
|
||||
'turn/start',
|
||||
'user/message',
|
||||
'web/deepseek-search-llm-request',
|
||||
])
|
||||
@@ -30,8 +30,23 @@ export function SessionId(id: string): SessionId {
|
||||
* and enforced by every persistence backend on load. The single source of truth for the
|
||||
* version — write sites and the load-time check all read it.
|
||||
* While the harness is unreleased it is pinned at `0`: no compatibility is
|
||||
* implied, incompatible logs are rejected, and no migration is provided. A
|
||||
* monotonic version policy starts with the first tagged release.
|
||||
* implied, incompatible logs are rejected, and no migration is provided.
|
||||
*
|
||||
* The version is a single monotonic integer with no major/minor split. Whether
|
||||
* a bump is needed is decided by what the WRITER emits, never by what a newer
|
||||
* reader can accept: bump exactly when an older runtime could no longer handle
|
||||
* a new log with full semantic correctness ("parses without error" is not
|
||||
* correctness — silently skipping content that shapes reconstruction is a
|
||||
* wrong read). Only structural changes reach that bar: the header shape, the
|
||||
* {@link SessionEvent} envelope, core event semantics, or the surface
|
||||
* mechanism (the {@link SurfaceEventType} set and {@link SurfaceOp} variants).
|
||||
* Adding an ordinary event type does not bump — the per-event
|
||||
* {@link SessionEvent.ignorable} guard covers vocabulary growth instead. When
|
||||
* in doubt, bump: a near-identity upgrade step is almost free, a missed bump
|
||||
* makes older runtimes read new logs wrong silently. The full mechanism
|
||||
* (upgrade-step chain, in-memory view conversion, migrate-on-continue) is
|
||||
* recorded in the session-log-version-mechanism Agent Note
|
||||
* (`.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md`).
|
||||
*/
|
||||
export const SESSION_FORMAT_VERSION = 0
|
||||
|
||||
@@ -389,6 +404,17 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
/** Unix epoch milliseconds. */
|
||||
time: number
|
||||
data: SessionEventMap[K]
|
||||
/**
|
||||
* Marks an event a reader may safely skip when it does not recognize
|
||||
* `type`. Absent means required: a reader meeting an unrecognized type
|
||||
* without this marker MUST refuse to reconstruct the session instead of
|
||||
* silently dropping the event, because an unrecognized required event may
|
||||
* change how the rest of the log is interpreted. A writer sets `true` only
|
||||
* on purely informational records whose loss cannot affect reconstruction;
|
||||
* defaulting to required means a forgotten marker over-refuses (an
|
||||
* inconvenience) rather than silently resuming a gutted session.
|
||||
*/
|
||||
ignorable?: true
|
||||
} & (K extends SurfaceEventType ? {
|
||||
/**
|
||||
* Seq numbers of earlier events that this event cites as sources
|
||||
|
||||
@@ -1090,12 +1090,20 @@ describe('Session', () => {
|
||||
{ ...base, time: '1' },
|
||||
{ ...base, time: 0.5 },
|
||||
{ type: base.type, seq: base.seq, time: base.time },
|
||||
{ ...base, ignorable: false },
|
||||
{ ...base, ignorable: 'yes' },
|
||||
]
|
||||
|
||||
for (const [index, event] of cases.entries()) {
|
||||
expect(() => Session.create(SessionId(`bad-envelope-${index}`), [event as SessionEvent]))
|
||||
.toThrow(/invalid event envelope/)
|
||||
}
|
||||
|
||||
// `ignorable: true` is the one accepted marker value (unknown-type skip contract).
|
||||
const marked = Session.create(SessionId('ignorable-envelope'), [
|
||||
{ ...base, ignorable: true } as SessionEvent,
|
||||
])
|
||||
expect(marked.events[0]?.ignorable).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
|
||||
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
|
||||
const catalog = await collectToolCatalog()
|
||||
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
|
||||
expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'report', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
|
||||
expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'read_image', 'report', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
|
||||
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
|
||||
for (const entry of catalog) {
|
||||
for (const schema of entry.schemas) {
|
||||
|
||||
@@ -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/e2b/fs-e2b/README.md
|
||||
README.md: 1b66e84defb56cbfaa4a91d6ba6b48377fb52ca9
|
||||
README.zh.md: d9cd3ce1e109bf6b0b7fae02157d1ec6be51e575
|
||||
README.md: 9171989f968f144593107eb918fe75cd12de7768
|
||||
README.zh.md: 9f50bbe4c37bbbfb641690a690be45dbb5158258
|
||||
|
||||
@@ -9,6 +9,7 @@ E2B implementation of the [`@deepseek-ai/dsh-fs`](../../fs/fs/README.md) provide
|
||||
- **Remote identity and metadata** — relative paths resolve as POSIX paths against the caller cwd or `ctx.e2b.cwd`; GNU `realpath -mz` supplies canonical target identity without requiring the final file to exist, and ASCII/base64 plus strict NUL framing preserves newline and multibyte paths across the decoded SDK transport. `stat`, no-follow `lstat`, and stable one-level directory listings project E2B metadata into the filesystem seam; listings reuse returned metadata and resolve symbolic-link entries sequentially. Versions are opaque hashes of E2B metadata plus a per-write extended attribute.
|
||||
- **Execution-world paths** — canonical targets expose absolute POSIX process paths, percent-encoded `file:` URIs, and provider-owned containment checks, so generic subprocess consumers never parse E2B target ids or apply host path rules.
|
||||
- **UTF-8 reads** — whole reads and streamed reads preserve cross-chunk decoding, reject invalid UTF-8, and use the seam's 8192-byte NUL sample for binary detection. The model-facing tool still owns size selection and line windowing.
|
||||
- **Bounded raw-byte reads** — `readBytes` short-circuits on the stat size before any content transfer, then streams the remote object and cancels the stream at the first chunk past `maxBytes` (`FS_TOO_LARGE`), so neither an at-rest oversized file nor a post-stat grower is buffered whole in host memory. The empty-file quirk of the pinned SDK (content-length 0 returns `''` in stream format) yields an empty result.
|
||||
- **Atomic mutations** — writes create a random sibling staging directory, change it to mode `0700` before uploading content, and preserve an existing file's POSIX mode. Replacements publish through E2B's same-filesystem atomic rename. A guarded `createIfAbsent` publishes with remote `ln -T` instead, making the commit atomically no-replace even when a directory appears at the destination; metadata read from the staged file before that commit is projected to the target path for the returned version, so no fallible metadata request follows either commit point. E2B creates missing parent directories. Literal edits LF-normalize for matching, restore dominant CRLF storage, and serialize mutations per canonical target within the host process.
|
||||
- **Failures and cancellation** — E2B not-found, permission, abort, and other controller failures map to the existing `FsError` vocabulary. Cancellation is best-effort at earlier SDK request boundaries and checked immediately before publication. The signal is not forwarded into the rename or guarded-link commit, so cancellation cannot interrupt atomic publication or turn a committed write into a reported failure.
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
- **远程身份与元数据**:相对路径以调用方 cwd 或 `ctx.e2b.cwd` 为基准,按照 POSIX 路径解析;GNU `realpath -mz` 提供规范化目标身份,且不要求最终文件存在;ASCII/base64 加严格 NUL 分帧会在已解码的 SDK 传输中保留含换行符和多字节字符的路径。`stat`、不跟随链接的 `lstat` 和稳定的单层目录列表会把 E2B 元数据投影到文件系统 seam;目录列表会复用已返回的元数据,并依次解析符号链接条目。版本是 E2B 元数据与每次写入设置的扩展属性所组成的不透明哈希。
|
||||
- **执行世界路径**:规范化目标公开绝对 POSIX 进程路径、百分号编码的 `file:` URI,以及由提供方负责的包含关系检查,因此通用进程管理消费方无需解析 E2B 目标 ID,也不会套用宿主路径规则。
|
||||
- **UTF-8 读取**:完整读取和流式读取会保留跨分片解码、拒绝无效 UTF-8,并使用 seam 的 8192 字节 NUL 样本检测二进制内容。面向模型的工具仍负责选择大小和行窗口。
|
||||
- **有界原始字节读取**:`readBytes` 在任何内容传输之前先按 stat 大小短路,然后流式读取远程对象,并在第一个超过 `maxBytes` 的分片处取消流(`FS_TOO_LARGE`),因此静态超限文件和 stat 后增长的文件都不会被完整缓冲进宿主内存。所钉版本 SDK 的空文件怪癖(content-length 为 0 时 stream 格式返回 `''`)产生空结果。
|
||||
- **原子变更**:写入会创建随机的同级暂存目录,在上传内容前将其 mode 改为 `0700`,并保留现有文件的 POSIX mode。替换操作通过 E2B 的同一文件系统原子重命名发布。带防护的 `createIfAbsent` 改用远程 `ln -T` 发布,即使目标位置出现目录,也能使提交具备原子且不替换的语义;系统会把提交前从暂存文件读取的元数据投影到目标路径,以生成返回的版本,因此任何一类提交点之后都不会再进行可能失败的元数据请求。E2B 会创建缺失的父目录。字面量编辑匹配时会规范化为 LF,存储时恢复占主导的 CRLF,并在宿主进程内按规范化目标串行执行变更。
|
||||
- **失败与取消**:E2B 的未找到、权限、中止及其他控制器故障会映射到现有 `FsError` 词汇。取消在更早的 SDK 请求边界上采用尽力而为语义,并在发布前立即检查。信号不会传入 rename 或防护链接提交,因此取消无法中断原子发布,也不会把已提交的写入报告为失败。
|
||||
|
||||
|
||||
@@ -90,6 +90,24 @@ function commandOpts(signal: AbortSignal | undefined): { envs: Record<string, st
|
||||
return { envs: e2bControlEnvs(), ...signalOpts(signal) }
|
||||
}
|
||||
|
||||
async function openReadStream(
|
||||
sandbox: Sandbox,
|
||||
target: FsTarget,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<ReadableStream<Uint8Array>> {
|
||||
try {
|
||||
// The pinned SDK's stream overload lies for empty files: content-length 0
|
||||
// returns '' instead of a ReadableStream.
|
||||
const read = await sandbox.files.read(String(target.targetKey), { format: 'stream', ...signalOpts(signal) }) as
|
||||
ReadableStream<Uint8Array> | string
|
||||
return typeof read === 'string'
|
||||
? new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
|
||||
: read
|
||||
} catch (error: unknown) {
|
||||
throw mapError(error, 'read', target.displayPath, signal)
|
||||
}
|
||||
}
|
||||
|
||||
function entryType(entry: EntryInfo): FsInfo['type'] {
|
||||
switch (entry.type) {
|
||||
case FileType.FILE:
|
||||
@@ -227,21 +245,57 @@ export class E2BFileSystem extends FileSystem {
|
||||
}
|
||||
}
|
||||
|
||||
override async readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array> {
|
||||
const sandbox = await this.ctx.e2b.getSandbox()
|
||||
const info = await this.requireRegular(target, signal)
|
||||
if (info.size !== undefined && info.size > maxBytes) {
|
||||
throw new FsError(`cannot read "${target.displayPath}": ${info.size} bytes exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE')
|
||||
}
|
||||
const stream = await openReadStream(sandbox, target, signal)
|
||||
const reader = stream.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let bytes = 0
|
||||
let completed = false
|
||||
try {
|
||||
while (true) {
|
||||
assertNotAborted(signal, 'read')
|
||||
const next = await reader.read()
|
||||
if (next.done) break
|
||||
// The stat preflight covers the at-rest case; this streamed bound stops
|
||||
// a post-stat grower without transferring past the first overflowing chunk.
|
||||
bytes += next.value.byteLength
|
||||
if (bytes > maxBytes) {
|
||||
throw new FsError(`cannot read "${target.displayPath}": content exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE')
|
||||
}
|
||||
chunks.push(next.value)
|
||||
}
|
||||
completed = true
|
||||
} catch (error: unknown) {
|
||||
throw mapError(error, 'read', target.displayPath, signal)
|
||||
} finally {
|
||||
if (!completed) {
|
||||
try {
|
||||
await reader.cancel()
|
||||
} catch (_streamCancellationFailure) {
|
||||
// The read already failed; a cancellation failure on the abandoned
|
||||
// remote stream adds nothing actionable for the caller.
|
||||
}
|
||||
}
|
||||
reader.releaseLock()
|
||||
}
|
||||
const whole = new Uint8Array(bytes)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
whole.set(chunk, offset)
|
||||
offset += chunk.byteLength
|
||||
}
|
||||
return whole
|
||||
}
|
||||
|
||||
override async streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
|
||||
const sandbox = await this.ctx.e2b.getSandbox()
|
||||
await this.requireRegular(target, signal)
|
||||
let stream: ReadableStream<Uint8Array>
|
||||
try {
|
||||
// The pinned SDK's stream overload lies for empty files: content-length 0
|
||||
// returns '' instead of a ReadableStream.
|
||||
const read = await sandbox.files.read(String(target.targetKey), { format: 'stream', ...signalOpts(signal) }) as
|
||||
ReadableStream<Uint8Array> | string
|
||||
stream = typeof read === 'string'
|
||||
? new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
|
||||
: read
|
||||
} catch (error: unknown) {
|
||||
throw mapError(error, 'read', target.displayPath, signal)
|
||||
}
|
||||
const stream = await openReadStream(sandbox, target, signal)
|
||||
const displayPath = target.displayPath
|
||||
return {
|
||||
async *[Symbol.asyncIterator](): AsyncGenerator<string> {
|
||||
@@ -412,10 +466,11 @@ export class E2BFileSystem extends FileSystem {
|
||||
}
|
||||
}
|
||||
|
||||
private async requireRegular(target: FsTarget, signal?: AbortSignal): Promise<void> {
|
||||
private async requireRegular(target: FsTarget, signal?: AbortSignal): Promise<FsInfo> {
|
||||
const info = await this.stat(target, signal)
|
||||
if (info === undefined) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND')
|
||||
if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
||||
return info
|
||||
}
|
||||
|
||||
private checkWriteIntent(existing: EntryInfo | undefined, expected: FsWriteIntent | undefined, target: FsTarget): void {
|
||||
|
||||
@@ -40,6 +40,7 @@ class FakeRemote {
|
||||
readonly links: Array<{ from: string; to: string }> = []
|
||||
readonly removals: string[] = []
|
||||
readonly commands: string[] = []
|
||||
readonly reads: Array<{ path: string; format: 'bytes' | 'stream' }> = []
|
||||
streamChunks: Uint8Array[] | undefined
|
||||
streamKeepOpen = false
|
||||
readonly streamCancel = vi.fn()
|
||||
@@ -157,6 +158,7 @@ class FakeRemote {
|
||||
},
|
||||
read: async (path: string, options: { format: 'bytes' | 'stream'; signal?: AbortSignal }): Promise<Uint8Array | ReadableStream<Uint8Array> | string> => {
|
||||
this.checkAbort(options)
|
||||
this.reads.push({ path, format: options.format })
|
||||
if (this.nextReadError !== undefined) {
|
||||
const error = this.nextReadError
|
||||
this.nextReadError = undefined
|
||||
@@ -471,6 +473,42 @@ describe('E2BFileSystem identity, metadata, and reads', () => {
|
||||
await expectCode(fs.streamText(raced), 'FS_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('readBytes returns raw content, enforces the byte cap, and maps failures', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/img.bin', [0x89, 0, 0xff, 0x47])
|
||||
remote.dir('/workspace/directory')
|
||||
const { fs } = await setup(remote)
|
||||
const target = await fs.resolve('img.bin')
|
||||
expect(Array.from(await fs.readBytes(target, undefined, 4))).toEqual([0x89, 0, 0xff, 0x47])
|
||||
expect(remote.reads).toEqual([{ path: '/workspace/img.bin', format: 'stream' }])
|
||||
remote.reads.length = 0
|
||||
await expectCode(fs.readBytes(target, undefined, 3), 'FS_TOO_LARGE')
|
||||
expect(remote.reads).toEqual([])
|
||||
await expectCode(fs.readBytes(await fs.resolve('missing'), undefined, 4), 'FS_NOT_FOUND')
|
||||
await expectCode(fs.readBytes(await fs.resolve('directory'), undefined, 4), 'FS_NOT_REGULAR_FILE')
|
||||
|
||||
const live = new AbortController()
|
||||
expect((await fs.readBytes(target, live.signal, 4)).byteLength).toBe(4)
|
||||
remote.nextReadError = new DOMException('aborted', 'AbortError')
|
||||
await expectCode(fs.readBytes(target, undefined, 4), 'FS_ABORTED')
|
||||
})
|
||||
|
||||
it('readBytes bounds a post-stat grower mid-stream and reads an empty file through the SDK quirk', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/grow.bin', [1, 1, 1, 1])
|
||||
remote.file('/workspace/empty.bin', '')
|
||||
const { fs } = await setup(remote)
|
||||
|
||||
remote.streamChunks = [bytes([1, 1, 1]), bytes([1, 2, 2])]
|
||||
remote.streamKeepOpen = true
|
||||
await expectCode(fs.readBytes(await fs.resolve('grow.bin'), undefined, 4), 'FS_TOO_LARGE')
|
||||
expect(remote.streamCancel).toHaveBeenCalledOnce()
|
||||
|
||||
remote.streamChunks = undefined
|
||||
remote.streamKeepOpen = false
|
||||
expect((await fs.readBytes(await fs.resolve('empty.bin'), undefined, 4)).byteLength).toBe(0)
|
||||
})
|
||||
|
||||
it('honors aborts before and during remote reads', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/a', 'a')
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# AGENTS.md — Experimental and internal packages
|
||||
|
||||
These rules supplement the [package rules](../AGENTS.md). The [experimental and internal package group decision](../../.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md) owns the rationale.
|
||||
|
||||
- All Cordis plugin packages whose full public contract is experimental or internal-only belong here. An experimental option inside an otherwise stable package stays in that package's product-role group.
|
||||
- Use this directory to share engineering and product-manager prototypes across the team so others can discover, run, review, and extend them against the real plugin graph.
|
||||
- Official releases exclude this directory. A package enters a release only after moving to its product-role group; do not add packages here to release manifests or bundles.
|
||||
- Experimental packages carry no stability, compatibility, migration, or support promise. Internal-only packages may define contracts for a limited set of internal callers and callees but make no public release promise.
|
||||
- Experimental or internal-only status never relaxes repository engineering, security, documentation, lifecycle, testing, or snapshot requirements.
|
||||
- Release packages must not take runtime dependencies on packages here. Examples may; every other runtime dependent is also experimental or internal-only and belongs here. Tests may use them as development dependencies.
|
||||
- Promotion moves a package to its product-role group without renaming its `@deepseek-ai/dsh-*` package. Require explicit review of its public contract, limitations, test evidence, and a named owner accepting stable-package obligations.
|
||||
@@ -1,7 +0,0 @@
|
||||
# experimental/ — experimental and internal packages
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
This group hosts team-shared engineering and product-manager prototypes plus internal-only Cordis plugins. It is excluded from official releases; packages move to their product-role group before release.
|
||||
|
||||
No packages live here yet. The [subtree rules](AGENTS.md) define the no-warranty, dependency, and promotion boundaries.
|
||||
@@ -1,7 +0,0 @@
|
||||
# experimental/:实验性与内部专用包
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
该分组容纳工程人员与产品经理在团队内共享的原型,以及内部专用 Cordis 插件。该分组不纳入官方发布版本;包在发布前移入对应的产品角色分组。
|
||||
|
||||
该分组尚未包含任何包。[子树规则](AGENTS.md)界定不作保证、依赖关系和提升机制的边界。
|
||||
@@ -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/feedback/command-feedback/README.md
|
||||
README.md: 52b8fb6a423fca69f76397deec36ecd22a6a6023
|
||||
README.zh.md: ca74d53f2531a46c2c16aa1423cee52e89c8256f
|
||||
README.md: 24a975476b6783b439d4ec94c449f2acbe0b432f
|
||||
README.zh.md: 12a4dcace001442351916b17fca0d7e2f2c76245
|
||||
|
||||
@@ -8,11 +8,24 @@ Trigger-independent session feedback plus human-facing `/feedback` capture. The
|
||||
|
||||
| Input | Result |
|
||||
|---|---|
|
||||
| `/feedback <text>` | Append `feedback/record` and acknowledge with `Feedback recorded for session {sessionId}` followed by `User: {userId}`. |
|
||||
| `/feedback <text>` | Append `feedback/record` and acknowledge with `Feedback recorded for session {sessionId}`, `User: {userId}`, plus the session-sharing disclosure. |
|
||||
| `/feedback` | Return a direct usage error. Whitespace-only input is treated as empty. |
|
||||
|
||||
Surrounding whitespace is discarded, but feedback is otherwise unparsed: no truncation, case folding, or control words. Text that looks like another command, such as `/feedback /plan felt slow`, is feedback content. Repeated commands each produce their own event; nothing is replaced or merged.
|
||||
|
||||
## Session-sharing disclosure
|
||||
|
||||
The acknowledgement names the receiving session id and reports how that session is shared, read from the mounted [`telemetry`](../../session/session-telemetry/README.md) service through the plugin context (`ctx.get('telemetry')`, never a declared injection). The disclosure is one sentence chosen from the backend's [`TelemetrySharingStatus`](../../session/session-telemetry/README.md):
|
||||
|
||||
| Disclosed status | Acknowledgement sentence |
|
||||
|---|---|
|
||||
| `full` | `Session sharing is enabled.` |
|
||||
| `feedback-only` | `Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.` |
|
||||
| `disabled` | `Session sharing is disabled.` |
|
||||
| no service | `Session sharing is not configured.` |
|
||||
|
||||
The disclosure states the deployment's current sharing policy only; it never promises delivery or retention. With `full` or `feedback-only`, records are handed to the backend's non-blocking enqueue and the SDK owns batching, retry, and loss policy, so the sentence claims nothing about what reached a collector; `disabled` claims nothing about future reconfiguration. The disclosure adds no event and never enters the model surface.
|
||||
|
||||
## What this plugin does and does not do
|
||||
|
||||
`recordFeedback(session, text)` is the command-independent write path. It rejects empty normalized text and appends `feedback/record { text }`; a different UI, hook, or host integration can call it without constructing a slash command. The `/feedback` handler uses that producer and starts no model work. The optional [`dsh-session-telemetry-otel`](../../session/session-telemetry-otel) consumer observes the event without changing its capture contract.
|
||||
@@ -56,4 +69,5 @@ Independent of the model request path. Recording appends to the session log only
|
||||
- **No structured fields** — an entry is one free-text string with no category, severity, or referenced-event link, so feedback cannot be filtered by subject without re-reading its text.
|
||||
- **No amend or withdraw** — the session log is append-only and this package adds no tombstone, so a mistaken entry stays recorded and can only be superseded by a later one.
|
||||
- **No explicit durability barrier** — the acknowledgement follows the append, not a flush, so an entry recorded immediately before a crash can be lost with any other unflushed tail. Feedback is not worth forcing a synchronous disk write for; a consumer that needs one awaits `ctx.sessions.flush(session)`.
|
||||
- **No visible acknowledgement on a fresh session** — the web transcript renders command rows only once a session is active, so `/feedback` on a still-blank session records the event but shows no acknowledgement row. Recording feedback after the first message renders normally.
|
||||
- **Web only among the shipped entry points** — headless mode, ACP automation, and JSON-RPC do not provide a command adapter, so `/feedback` is unavailable there.
|
||||
|
||||
@@ -8,11 +8,24 @@
|
||||
|
||||
| 输入 | 结果 |
|
||||
|---|---|
|
||||
| `/feedback <text>` | 追加 `feedback/record`,并以 `Feedback recorded for session {sessionId}` 确认,随后显示 `User: {userId}`。 |
|
||||
| `/feedback <text>` | 追加 `feedback/record`,并以 `Feedback recorded for session {sessionId}`、`User: {userId}` 加会话共享披露确认。 |
|
||||
| `/feedback` | 返回一个直接用法错误。仅含空白的输入视为空输入。 |
|
||||
|
||||
前后空白会被丢弃,但除此之外,反馈内容不会被解析:没有截断、大小写折叠或控制词。看起来像另一个命令的文本(例如 `/feedback /plan felt slow`)就是反馈内容。重复执行命令时,每次都会产生一个事件;不会发生替换或合并。
|
||||
|
||||
## 会话共享披露
|
||||
|
||||
确认文本会点名接收会话的 id,并报告该会话如何被共享;该信息通过插件上下文(`ctx.get('telemetry')`,绝不是声明的注入)从已挂载的 [`telemetry`](../../session/session-telemetry/README.md) 服务读取。披露是依据后端 [`TelemetrySharingStatus`](../../session/session-telemetry/README.md) 选择的一句话:
|
||||
|
||||
| 披露的状态 | 确认文本中的句子 |
|
||||
|---|---|
|
||||
| `full` | `Session sharing is enabled.` |
|
||||
| `feedback-only` | `Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.` |
|
||||
| `disabled` | `Session sharing is disabled.` |
|
||||
| 无服务 | `Session sharing is not configured.` |
|
||||
|
||||
披露只陈述部署当前的共享策略,绝不承诺投递或留存:在 `full` 或 `feedback-only` 下,记录被交给后端的非阻塞入队,批处理、重试与丢失策略归 SDK 负责,因此句子不声称任何内容已到达采集端;`disabled` 也不声称未来不会重新配置。披露不新增任何事件,也绝不会进入模型 surface。
|
||||
|
||||
## 本插件做什么、不做什么
|
||||
|
||||
`recordFeedback(session, text)` 是不依赖命令的写入路径。它拒绝规范化后为空的文本,并追加 `feedback/record { text }`;其他 UI、钩子或 host 集成无需构造斜杠命令即可调用它。`/feedback` 处理器通过该生产方写入,且不启动任何模型工作。可选的 [`dsh-session-telemetry-otel`](../../session/session-telemetry-otel) 消费方会观察该事件,但不改变它的采集约定。
|
||||
@@ -56,4 +69,5 @@
|
||||
- **没有结构化字段**:一条条目就是一个自由文本字符串,没有类别、严重程度或关联事件链接,因此无法在不重读文本的情况下按主题过滤反馈。
|
||||
- **不支持修改或撤回**:会话日志是仅追加的,本包也不新增 tombstone,因此错误的条目会一直保留在记录中,只能由后续条目取代。
|
||||
- **没有显式持久化屏障**:确认文本紧随追加而非 flush,因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。为反馈强制同步写盘并不值得;需要该保证的消费方可自行等待 `ctx.sessions.flush(session)`。
|
||||
- **新会话上没有可见的确认**:Web 转录只在会话激活后渲染命令行,因此在仍为空白的新会话上执行 `/feedback` 会记录事件但不会显示确认行。发送首条消息后再记录反馈即可正常渲染。
|
||||
- **随附的产品入口中只有 Web 使用此命令**:无头模式、ACP 自动化和 JSON-RPC 不提供命令适配器,因此 `/feedback` 在那里不可用。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user