diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml index 5ee9d06358..37ab609908 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md -2026-08-06-app-owned-command-line.md: 2480775f654fd5c2fecebc8d59e311acee878920 -2026-08-06-app-owned-command-line.zh.md: d754c125d5bc683156f5ac3f285e2cd711e6773b +2026-08-06-app-owned-command-line.md: 6d84ba457564ef250e1acfbcc71fcc91b1d49aee +2026-08-06-app-owned-command-line.zh.md: f964f7a7de7aae7e97b52fbc572443352dc5ae26 diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md index 2480775f65..6d84ba4575 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md @@ -12,7 +12,7 @@ After profiles, compositions were installable but their command lines were not. The launcher parses only what it owns — `--profile`, `--patch`, the config dumps — and hands **everything after its own flags** to the booted tree verbatim. The split is positional: the first token the launcher does not recognize starts the app's arguments (commander's `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`). A bare `dsh -h`, which has no app to hand the flag to, still prints the launcher's own help. -The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`) and `ctx.appExit`. Any ordinary app plugin may inject `cmdlineArgs`, call `parseCmdline(ctx, program, plan)` with its own commander program, and provide the returned value as an app-owned service. Its Loader row carries no launcher marker or special kind, and the launcher does not inspect the composition for an owner. Multiple plugins may read the same immutable snapshot; a profile with no reader ignores its app arguments. Rows configured from a provider inject its service and read direct lazy config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. +The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`) and `ctx.appExit`. Any ordinary app plugin may inject `cmdlineArgs`, call `parseCmdline(ctx, program)` with its own commander program, and provide the resolved value as an app-owned service from the program's action. Its Loader row carries no launcher marker or special kind, and the launcher does not inspect the composition for an owner. Multiple plugins may read the same immutable snapshot; a profile with no reader ignores its app arguments. Rows configured from a provider inject its service and read direct lazy config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. The boot mounts the composition once. Cordis holds each row until its injections are active; Loader then interpolates that row's `!!js` against the injection-ready plugin context immediately before activation. Include keeps nested row expressions raw until their target row reaches this point. `--help` leaves the provider's service absent, so dependent rows never activate, and a live patch reload interpolates again against the service that remains active, so a served port cannot be silently reset. diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md index d754c125d5..f964f7a7de 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md @@ -12,7 +12,7 @@ profile 落地之后,组合可以安装,命令行却不能。`apps/cli` 仍 启动器只解析属于自己的部分(`--profile`、`--patch`、配置 dump),并把**自己 flag 之后的一切**原样交给引导起来的配置树。切分按位置进行:启动器不认识的第一个 token 就是应用参数的起点(依靠 commander 的 `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`)。裸的 `dsh -h` 没有可交付的应用,仍然打印启动器自己的 help。 -新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)与 `ctx.appExit`。任何普通应用插件都可以注入 `cmdlineArgs`,用自己的 commander program 调用 `parseCmdline(ctx, program, plan)`,再把返回值作为应用自有服务提供出去。它的 Loader 行不携带启动器标记或特殊类型,启动器也不会检查组合中的所有者。多个插件可以读取同一份不可变快照;没有读取方的 profile 会忽略自己的应用参数。由提供方配置的行注入其服务,并在惰性配置表达式中直接读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 +新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)与 `ctx.appExit`。任何普通应用插件都可以注入 `cmdlineArgs`,用自己的 commander program 调用 `parseCmdline(ctx, program)`,再在 program 自己的 action 中把解析出的取值作为应用自有服务提供出去。它的 Loader 行不携带启动器标记或特殊类型,启动器也不会检查组合中的所有者。多个插件可以读取同一份不可变快照;没有读取方的 profile 会忽略自己的应用参数。由提供方配置的行注入其服务,并在惰性配置表达式中直接读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Loader 随后在激活前一刻,基于已注入就绪的插件上下文插值该行的 `!!js`。Include 会保留嵌套的行表达式,直到目标行到达这一时点。`--help` 会让提供方服务保持缺失,因此依赖行永不激活;活动 patch 重载会针对仍然在线的服务再次插值,所以已经服务中的端口不会被悄悄重置。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml index 67d2b330b1..72878df41d 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md -2026-08-11-preset-authoring-agent-validates-its-own-composition.md: 6b9cdf32b70e3ab4adc9f3b0e20bb3d2245486c7 -2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md: e6e8dabcd886a6331d294744b667552caa01e7b4 +2026-08-11-preset-authoring-agent-validates-its-own-composition.md: eb21094f0d859a31d5f16d780cada6818a508b36 +2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md: 02c245348a9c7e9968472044d7ff95e1ff21120c diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md index 6b9cdf32b7..eb21094f0d 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md @@ -32,7 +32,9 @@ The agent reaches the roster service the way `cordis_mount` documents: a tempora "Whether a row publishes a service" resolves through `cordis_inspect what:"services"`, which names the owning fiber of every live service. -The guidance keeps `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` as the answer to "where do my presets live" — it is where every `dsh` launcher puts them — while routing the path an agent actually reads or edits through `list()` or `resolve()`. `Config.roots` defaults to `[]` and `apps/cli` patches both roots in, `writableRoot()` takes the first `user` one, and no call reports either path; `authorable` answers only whether a writable root exists, and `list()` cannot reveal a user root that holds nothing yet. Stating the path is therefore right for talking to a person and wrong for feeding a file tool. +The guidance keeps `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` as the answer to "where do my presets live" while routing the path an agent actually reads or edits through `list()` or `resolve()`. Stating the path is right for talking to a person and wrong for feeding a file tool: a deployment may configure other roots, and `list()` cannot reveal a user root that holds nothing yet. + +That path is now a property of the package rather than of one launcher. `AgentPresets` derives `/.agent-presets` as a `user` root unless `includeUserRoot` is false, the way [`dsh-skill-local`](../../../../packages/skill/skill-local/README.md) derives `/skills`, and `apps/cli` supplies only the SHIPPED root — the one path an installed app alone can resolve. The asymmetry it replaces cost a bug: with both roots patched in by one launcher, `dsh run` booted a roster with no roots at all and failed resolving `standard` (fixed then by teaching every launcher the patch). The derived root is appended after every configured root, so a shipped id still shadows a home directory claiming it, and `writableRoot()` still prefers an explicitly configured `user` root. It is resolved once at construction: a root set that changed between a `list()` and the `copy()` acting on its answer would author into a directory the caller never saw. The prohibition on touching the shipped install is promoted from a paragraph inside the authoring steps to a top `## Off-limits` section, extended to cover editing the host composition as a workaround. The new self-validation calls do not weaken it: `copy()` refuses an id any root supplies, and `remove()` refuses a preset that ships with the deployment. diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md index e6e8dabcd8..02c245348a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md @@ -32,7 +32,9 @@ agent 按 `cordis_mount` 自身文档所述的方式够到 roster 服务:挂 「某行是否发布服务」改由 `cordis_inspect what:"services"` 回答,它会给出每个存活服务的持有 fiber。 -指导保留 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` 作为「我的 preset 在哪」的答案——每个 `dsh` 启动器都把它们放在那里——同时把 agent 实际读取或编辑的路径改走 `list()` 或 `resolve()`。`Config.roots` 默认为 `[]`,两个根均由 `apps/cli` 补入,`writableRoot()` 取其中第一个 `user` 根,且没有任何调用会报告任一路径;`authorable` 只回答是否存在可写根,而 `list()` 无法揭示一个尚且为空的用户根。因此写出该路径对人讲是对的,喂给文件工具是错的。 +指导保留 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` 作为「我的 preset 在哪」的答案,同时把 agent 实际读取或编辑的路径改走 `list()` 或 `resolve()`。写出该路径对人讲是对的,喂给文件工具是错的:部署可以配置其他根目录,而 `list()` 无法揭示一个尚且为空的用户根。 + +该路径如今是本包的属性,而非某个启动器的属性。除非 `includeUserRoot` 为 false,`AgentPresets` 自行推导 `/.agent-presets` 作为 `user` 根,正如 [`dsh-skill-local`](../../../../packages/skill/skill-local/README.md) 推导 `/skills`;`apps/cli` 只提供**随附**根——那是唯有已安装 app 才能解析的路径。它取代的那种不对称曾付出过代价:两个根都由单一启动器补入时,`dsh run` 启动的 roster 一个根都没有,解析 `standard` 直接失败(当时的修法是让每个启动器都执行该 patch)。推导出的根追加在全部已配置根之后,因此随附 id 仍会遮蔽占用它的家目录目录,而 `writableRoot()` 仍优先选择显式配置的 `user` 根。它在构造时解析一次:若根目录集合在一次 `list()` 与依据其答案执行的 `copy()` 之间发生变化,写入的将是调用方从未见过的目录。 禁止改动随发布安装的约束,从创作步骤中的一段提升为顶部的 `## Off-limits` 一节,并扩展到禁止改宿主组装绕行。新增的自校验调用不削弱它:`copy()` 拒绝任何根已提供的 id,`remove()` 拒绝随部署发布的 preset。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.i18n.yaml new file mode 100644 index 0000000000..cc3873f137 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md +2026-08-12-onboarding-reads-every-provider.md: 1f247a6c93257c24052f55eb4297ec3c9c3df06d +2026-08-12-onboarding-reads-every-provider.zh.md: fc6e43195a46eaea881f8b4bee3219b5e583b284 diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md new file mode 100644 index 0000000000..1f247a6c93 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md @@ -0,0 +1,38 @@ +# Agent Note: First-run readiness reads every provider, and the setup card closes + +Status: implemented + +English | [中文](2026-08-12-onboarding-reads-every-provider.zh.md) + +## Problem + +The first-run step and the Models page both asked one question — is `deepseek-official`'s credential stored? — of a join that describes every provider. Two defects followed from that single reading. + +A user who configured some other provider (a pi-ai gateway, a self-hosted route) and never wanted the official DeepSeek endpoint was taken over by the full-screen credential prompt on every blank session, with a working model already selected in the composer behind it. Nothing they could do short of storing a DeepSeek key would end it, because the step's readiness projection never looked at the row they had configured. + +On the Models page the same reading opened the DeepSeek setup card over them on every visit, and that card could not be closed: it was rendered from row data with no local state a Cancel could flip, so its Cancel button did nothing visible. Worse, it shared the row-editor/add/declare close handler, which unconditionally clears all three of those states — so cancelling the card that owned none of them discarded the add card's draft while staying open itself. + +## Decision + +One predicate answers what both surfaces actually need. `providerUsable(row)` is true when the route is registered with the adapter registry (`entry.active`) and whatever credential its resolved profile names is stored; a profile naming no reference authenticates through the provider's own path, as does a live route with no settings address, so neither owes this page a key. + +`onboardingReadiness` (renamed from `deepSeekReadiness`, which no longer describes what it reads) returns `provider-ready` as soon as any joined row is usable. Only a user with none of those reaches the official DeepSeek lookup, which is unchanged: it is the one route the prompt can offer a key field for. The gate subsumes two diagnostics the old projection carried — `settings-unavailable` and `credential-ref-unavailable` — because both described an active route the new gate now calls usable; the outcome for the user was already identical (the step completed without rendering). + +`needsSetup(row, anyUsable)` takes the same fact, so the setup card is the first-run posture alone. With another provider reachable, DeepSeek is an ordinary row carrying the missing-key dot, one Edit click from the same card. + +Each card kind now owns its own close handler. `closeSetup` records the provider in a component-local `dismissedSetup` set and touches nothing else; `closeEditor` keeps clearing the three states its cards own. Both route the post-save reload through one `announceSaved` helper. Dismissal is viewing state, like the open editor and the add card: a reload restores the first-run posture for a user still in it. + +## Alternatives considered + +- **Deriving readiness from the model catalog (`llm.models`) instead of the join.** It answers "can the user talk to something" most directly, but it costs a per-provider listing round trip on a surface that already holds the join, and a provider whose listing fails transiently would re-open onboarding. +- **Requiring `row.configured` in `providerUsable`.** It reads as the stricter check, and would exclude exactly the routes a deployment mounts through `cordis.yml` without a configurable-provider declaration — live routes serving models that this page cannot configure. Registration, not configurability, is what makes a provider usable. +- **Only adding the dismissal, leaving the card auto-opening.** It fixes the Cancel button and nothing else: a user with a working provider would still be handed the DeepSeek form on every visit to Models, which is the same misreading in a quieter form. +- **Persisting the dismissal to settings.** A durable "do not ask about DeepSeek" flag is a second fact about first-run state that can disagree with the join. The credential itself already ends the posture permanently, and every other card on this page is session-local. + +## Consequences + +Onboarding now ends for reasons the DeepSeek route knows nothing about, so the step's name is the last thing tying it to that adapter; a future step that offers more than one route to configure would replace the prompt, not the readiness projection. The narrowed diagnostic union means an unresolvable `llm-deepseek` settings address is reported as `provider-ready` rather than as its own reason — the user-visible behavior is unchanged, and the Models page remains the diagnostic surface. + +## Testing + +Package tests pin `providerUsable` over the four join states and `onboardingReadiness` over both the new gate and every surviving diagnostic; the section tests cover the first-run posture, the plain-row posture, and the cancel that collapses the setup card while the add card keeps its draft. The `onboarding-usable-provider` web e2e lane replays the whole scenario through the real wire: cancel with both cards open, configure `minimax-cn` instead, reload, and find no takeover — with one aria golden of the dismissed state. diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md new file mode 100644 index 0000000000..fc6e43195a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md @@ -0,0 +1,38 @@ +# Agent Note: First-run readiness reads every provider, and the setup card closes + +Status: implemented + +[English](2026-08-12-onboarding-reads-every-provider.md) | 中文 + +## Problem + +首次使用引导步骤与 Models 页都只向一个描述全部提供方的联接快照提出了同一个问题——`deepseek-official` 的凭据存了吗?两个缺陷由这一次读取而来。 + +配置了别的提供方(某个 pi-ai 网关、某条自建路由)、根本不打算用 DeepSeek 官方端点的用户,会在每一个空白会话上被全屏凭据提示接管,而其背后输入框里早已选好了一个可用模型。除了存入一把 DeepSeek 密钥,他们做什么都结束不了它——因为该步骤的就绪投影从不看他们已经配好的那一行。 + +在 Models 页上,同一次读取每次进入都会把 DeepSeek 设置卡片展开在他们面前,而这张卡片关不掉:它由行数据渲染而来,没有任何本地状态可供「取消」翻转,因此那颗取消按钮不产生任何可见效果。更糟的是,它与行内编辑卡/新增卡/自定义声明卡共用同一个关闭回调,而该回调会无条件清空那三个状态——于是取消一张它们一个都不拥有的卡片,反而丢弃了新增卡里的草稿,自己却仍然开着。 + +## Decision + +一个谓词回答两处界面真正需要的事实。`providerUsable(row)` 在路由已注册进适配器注册表(`entry.active`)、且其解析后 profile 所指名的凭据已存储时为真;不指名任何引用的 profile 走提供方自己的认证路径,没有 settings 地址的存活路由亦然,因此二者都不欠这个页面一把密钥。 + +`onboardingReadiness`(原名 `deepSeekReadiness`,该名称已不再描述它读取的内容)只要联接中有任意一行可用,就返回 `provider-ready`。只有二者皆无的用户才会走到官方 DeepSeek 查找,那部分保持不变:它是这条提示唯一能为其提供密钥输入框的路由。这道门槛吸收了旧投影携带的两个诊断——`settings-unavailable` 与 `credential-ref-unavailable`——因为二者描述的都是新门槛现在判为可用的活跃路由;对用户而言结果本就一致(该步骤不渲染直接完成)。 + +`needsSetup(row, anyUsable)` 接受同一个事实,因此设置卡片仅代表首次运行姿态。当另有可触达的提供方时,DeepSeek 就是一行带缺失密钥点的普通行,距离同一张卡片只有一次「编辑」点击。 + +现在每一类卡片各自拥有自己的关闭回调。`closeSetup` 把该提供方记入组件本地的 `dismissedSetup` 集合,别的一概不碰;`closeEditor` 继续清空它那些卡片所拥有的三个状态。两者都经由同一个 `announceSaved` 助手完成保存后的重载。关闭状态属于查看态,与展开的编辑卡和新增卡一样:对仍处于首次运行姿态的用户,重载会恢复该姿态。 + +## Alternatives considered + +- **从模型目录(`llm.models`)而非联接推导就绪状态。** 它最直接地回答「用户有没有能对话的东西」,但会在一个已经持有联接的界面上多花每提供方一次列举往返,而且某个提供方列举的瞬时失败会让引导重新弹出。 +- **在 `providerUsable` 中要求 `row.configured`。** 它读起来更严格,却会恰好排除部署通过 `cordis.yml` 挂载、没有可配置提供方声明的那些路由——它们是正在提供模型、只是这个页面配置不了的存活路由。使一个提供方可用的是注册,不是可配置性。 +- **只加关闭状态,保留卡片自动展开。** 那只修好取消按钮,别的什么都没修:已有可用提供方的用户每次进入 Models 仍会被塞一张 DeepSeek 表单,那是同一个误读的安静版本。 +- **把关闭状态持久化到 settings。** 一个「别再问 DeepSeek」的持久标志,是关于首次运行状态的第二个事实,可能与联接互相矛盾。凭据本身已经永久结束该姿态,而这个页面上其他每一张卡片都是会话内的。 + +## Consequences + +引导现在会因为 DeepSeek 路由一无所知的理由而结束,因此该步骤的名字是最后一处把它和那个适配器绑在一起的东西;未来若有一个步骤能提供不止一条可配置路由,替换掉的会是提示本身,而非就绪投影。收窄后的诊断联合意味着无法解析的 `llm-deepseek` settings 地址会被报为 `provider-ready` 而非它自己的理由——用户可见行为不变,Models 页仍是诊断界面。 + +## Testing + +包内测试针对四种联接状态钉住 `providerUsable`,并针对新门槛与每一个存留的诊断钉住 `onboardingReadiness`;分区测试覆盖首次运行姿态、普通行姿态,以及在新增卡保住草稿的同时折叠设置卡片的那次取消。`onboarding-usable-provider` web e2e 泳道通过真实协议重放整个场景:两张卡片都开着时取消、改配 `minimax-cn`、重载,然后不再出现接管——并附一份关闭后状态的 aria golden。 diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml index 097c0c9f2d..015852b082 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md -2026-07-25-session-list-browsing-and-manual-order.md: 3d2125bdf67a70a1a5bca43c5d5acb09fda178b7 -2026-07-25-session-list-browsing-and-manual-order.zh.md: 161ebd2857073d4dd9cfc2883880cd3e2d91c040 +2026-07-25-session-list-browsing-and-manual-order.md: 52a0fe0c94106cb4178c57e737b1c9a3f458f803 +2026-07-25-session-list-browsing-and-manual-order.zh.md: a6c44579c685479ca460da8e52ea885f20e4776b diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md index 3d2125bdf6..52a0fe0c94 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md @@ -14,7 +14,7 @@ Two existing mechanisms stood in the way. First, the host durably promoted the a ### Flat rows and viewing state -The group-by menu offers two modes, WorkSpace / In one list. WorkSpace mode renders peer session rows within each group in the manual order from `WorkspaceView.sessionIds`; In one list combines every session and sorts them strictly newest-first by `updatedAt`. Neither mode projects `parentId` into a list hierarchy; fork lineage remains session data only. [Web session fork actions](2026-07-27-web-session-fork-actions.md) define the complete fork behavior. The mode choice persists in the browser (`dsh.workspace.view`) across reloads. +The group-by menu offers two modes, WorkSpace / In one list. WorkSpace mode renders peer session rows within each group in the manual order from `WorkspaceView.sessionIds`; In one list combines every session and sorts them strictly newest-first by `updatedAt`. Neither mode projects `parentId` into a list hierarchy; fork lineage remains session data only. [Web session fork actions](2026-07-27-web-session-fork-actions.md) define the complete fork behavior. The mode choice persists in the browser (`dsh.workspace.view`) across reloads. [Workspace Sidebar Order and Folding](2026-08-11-workspace-sidebar-order-and-folding.md) later added a browser-local recent-update view without changing the Host account's manual-order authority. ### Row interactions @@ -50,7 +50,7 @@ ui-sidebar shrinks to the column-geometry shell: brand row, fold state machine, ## Consequences -- Manual order is the sole authority over the workspace account: an order the user arranges is never scrambled by activity; the cost is losing float-to-top-on-activity, whose signal now rides the row status dot and time label. The `WorkspaceView.sessionIds` wire contract is reworded to the manual-order semantics. +- Manual order is the sole authority over the Host workspace account: activity never mutates `WorkspaceView.sessionIds`. A later browser-local recent-update view may promote active rows without changing that account; its separate semantics are defined in [Workspace Sidebar Order and Folding](2026-08-11-workspace-sidebar-order-and-folding.md). - The two-fact shell/region contract funnels every future workspace-domain feature (Delete confirmation, cross-group moves, Ungrouped adoption) into the single ui-workspace package; ui-sidebar no longer evolves with session-list features. - Flat mode supports neither reordering nor a create-in-workspace entry point (switching back to grouped view is required) — an accepted scope reduction. - Wiring session Delete and growing the wire status enum remain future iterations. diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md index 161ebd2857..a6c44579c6 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md @@ -14,7 +14,7 @@ Status: implemented ### 平铺行与浏览态 -group-by 菜单提供 WorkSpace / In one list 两种模式。WorkSpace 模式按 `WorkspaceView.sessionIds` 的手动序在各组内展示同级 session 行;In one list 把所有 session 合并后严格按 `updatedAt` 新→旧排序。两种模式都不把 `parentId` 投影成列表层级,fork 谱系只保留为 session 数据;完整 fork 行为由 [Web session fork 操作](2026-07-27-web-session-fork-actions.md)定义。模式选择持久化在浏览器(`dsh.workspace.view`),刷新保持。 +group-by 菜单提供 WorkSpace / In one list 两种模式。WorkSpace 模式按 `WorkspaceView.sessionIds` 的手动序在各组内展示同级 session 行;In one list 把所有 session 合并后严格按 `updatedAt` 新→旧排序。两种模式都不把 `parentId` 投影成列表层级,fork 谱系只保留为 session 数据;完整 fork 行为由 [Web session fork 操作](2026-07-27-web-session-fork-actions.md)定义。模式选择持久化在浏览器(`dsh.workspace.view`),刷新保持。[Workspace 侧边栏顺序与折叠](2026-08-11-workspace-sidebar-order-and-folding.md)随后加入浏览器本地的最近更新视图,而未改变 Host 记账的手动顺序权威。 ### 行交互 @@ -50,7 +50,7 @@ ui-sidebar 缩为列几何壳:品牌行、折叠状态机、New Session、Settin ## Consequences -- 手动序是唯一的 workspace 账本序权威:用户排好的顺序不再被活动打乱;代价是「最近活跃浮到最上」的行为消失,活跃感知转由行内状态点与时间标签承担。`WorkspaceView.sessionIds` 的 wire 约定随之改为手动序措辞。 +- 手动序是 Host workspace 账本的唯一顺序权威:活动绝不改动 `WorkspaceView.sessionIds`。后续加入的浏览器本地最近更新视图可以把活跃行提到最前,但不会改变该账本;其独立语义见 [Workspace 侧边栏顺序与折叠](2026-08-11-workspace-sidebar-order-and-folding.md)。 - 壳/区域两事实约定把 workspace 域的后续功能(Delete 确认、跨组移动、Ungrouped 收编)全部收进 ui-workspace 单包;ui-sidebar 不再随 session 列表功能演进。 - 平铺模式不支持排序与分组入口(建到指定 workspace 需切回分组视图),是拍板接受的范围收窄。 - session Delete 的功能接线与状态枚举扩 wire,留待后续迭代。 diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml index c0813607d3..8d8d36e87d 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md -2026-07-25-workspace-ui-product-flow.md: 98e963195126df2ec8291a11b3d9fc7a2baeb0df -2026-07-25-workspace-ui-product-flow.zh.md: 486093be0b8d10c2ae0b8083b305ecad5386351c +2026-07-25-workspace-ui-product-flow.md: 76d279bf2101d7487fe4f5231c7cea4809e166f4 +2026-07-25-workspace-ui-product-flow.zh.md: e15ead7b437d8f2324f7ea51222eb4fcfb4a9e4a diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md index 98e9631951..76d279bf21 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md @@ -20,6 +20,7 @@ The Host provides the following GUI wiring on the Workspace entity: | --- | --- | | `workspace.list` | Returns persistent Workspaces in order and filters out Session ids that fail header validation | | `workspace.create({ path })` | Adopts an existing directory by canonical path; basename-derived display titles may repeat | +| `workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` | Moves one Workspace within durable registry order and returns the complete committed order | | `workspace.delete({ workspaceId })` | Removes the Workspace registration while retaining its directory and session logs; its Sessions become Ungrouped | | `session.create({ workspaceId, sessionId? })` | Resolves cwd from the Workspace, idempotently creates a Session with an optional preallocated id, and attaches it | | `session.create({ cwd })` | Remains available to non-Workspace callers and creates an Ungrouped Session | @@ -49,7 +50,7 @@ On initial entry, the application waits until both the Workspace and Session bas When no Workspace exists, the page creates a frontend Workspace object named `workspace` and a frontend Session that targets it. Neither writes to the Host, and the composer always accepts input; the first send materializes the Workspace, attaches the Session, and sends the message in that order. -Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the most recent Workspace, or the Workspace Intent if no real Workspace exists. The Workspace picker's one Add workspace action ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md); it was a pair of Use-an-existing-folder and create-by-name actions when this was decided) immediately creates a real Workspace when the user confirms a directory, then retargets the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message. +Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the current Session's Workspace, then the most recent Workspace, and enters the blank New Session page when no real Workspace exists. The Workspace picker's one Add workspace action ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md); it was a pair of Use-an-existing-folder and create-by-name actions when this was decided) immediately creates a real Workspace when the user confirms a directory, then retargets the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message. A new Workspace takes its display name from the directory it was created in. Distinct canonical paths may share the same basename-derived title ([identity decision](../bug-fix/2026-07-31-same-basename-workspace-adoption.md)); the explicit rename operation retains its duplicate-title check. Moving Sessions across Workspaces, manual adoption from Ungrouped, and separate display-name and directory-name inputs remain outside this flow. @@ -67,11 +68,11 @@ Lost RPC responses, Host frames arriving before completions, and completions arr ### Sidebar and ordering -Workspace groups strictly follow the persistent order returned by the Host. Bootstrap determines the historical order once, explicitly created Workspaces are placed first, and Session activity does not move Workspace groups. +Workspace groups follow the persistent order returned by the Host. Bootstrap determines the historical order once, explicitly created Workspaces are placed first, and `workspace.insertBefore` durably applies user drag order. Session activity does not move Workspace groups. -Within each group, order strictly follows `Workspace.sessionIds`. A newly attached Session is placed first; when a Session later becomes active, the Host moves only that id to the front and persists the change. The Client does not reorder the entire group by time after the Session list arrives, so it never displays one Workspace order and then jumps to another during hydration. +The Host account remains the manual `Workspace.sessionIds` order: a newly attached Session is placed first and activity does not mutate it. The grouped browser can instead select a browser-local recent-update view that promotes a Session when its `updatedAt` advances and remains manually editable. Five Sessions are visible per open Workspace until the user transiently expands the remainder. The durable Workspace reorder and browser-local Session order are defined in [Workspace Sidebar Order and Folding](2026-08-11-workspace-sidebar-order-and-folding.md). -A frontend Session Intent appears as a “New session” row and temporarily counts toward the group's Session total only when it targets a real Workspace. When it targets a Workspace Intent, neither the Workspace nor the Session appears in the sidebar. After the Intent is published, the real row with the same preallocated id takes its place; after refresh, both the Intent row and temporary count disappear. Search mode neither retains nor filters Intent rows. +The current blank Session appears as a “New session” row without a count, time label, or row menu; other blank Sessions remain hidden and eligible for per-Workspace reuse. Search excludes blank rows. Real Sessions that cannot be assigned to any Workspace appear under Ungrouped. Host `session-added` and `workspace-changed` events may arrive in either order; list merging does not depend on frame order. @@ -105,15 +106,15 @@ The Sidebar and conversation empty hero receive standardized actions through slo - Frontend Sessions and Workspaces preserve object identity across materialization; input, errors, focus, and sidebar projections always originate from the object layer. - The first send advances through Workspace, Session, and prompt in order; successful stages are not rolled back, input is not lost before the prompt is accepted, and creation retries use the same SessionId. - Workspace list performs one reentrant bootstrap using only headers; an initialized empty registry does not initialize again after restart, and membership reads validate both the index and canonical cwd. -- The initial default target is determined exactly once after both baselines are ready; Workspace groups are not reordered as a whole by hydration or Session activity, and an active Session moves only itself to the front. -- A frontend Session under a real Workspace temporarily counts toward the sidebar total, while a Workspace Intent remains hidden; neither publication nor refresh leaves duplicate rows or counts. +- The initial default target is determined exactly once after both baselines are ready; Workspace groups are not reordered by hydration or Session activity, and explicit Workspace drag order survives reconnect. +- The current blank Session can appear as a single New Session row without exposing other reusable blanks or a Session count. - The UI and Host admit distinct same-basename directories as separate Workspaces, while the explicit rename operation rejects duplicate titles; cwd-only Sessions, Sessions with invalid historical cwd values, and unattached Sessions remain Ungrouped. - Confirmed Workspace deletion removes only the registration, retains the current Session, directory, files, and session log, and survives reload; package tests pin unary/frame/baseline races and failure rollback. - Keyless runnable snapshots cover the zero state, explicit creation, and the first send; package-level tests cover bootstrap, membership validation, ordering, idempotency, failure recovery, and arbitrary frame order. ## Consequences -- SessionHeader does not record last-active time, so historical bootstrap can initialize order only by `createdAt`; real Session activity events move individual entries afterward. +- SessionHeader does not record last-active time, so historical bootstrap can initialize the Host manual order only by `createdAt`; the browser's optional recent-update view begins from Session summaries after hydration. - Historical Sessions with a missing cwd, an invalid directory, or a failed realpath remain Ungrouped; this iteration has no manual-adoption entry point. - Refreshing the page discards unmaterialized Workspace and Session Intents and input not yet accepted by the Host; this is the page-local contract. - Explicit Create Workspace writes to disk immediately, so leaving without sending still leaves an empty Workspace. diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md index 486093be0b..e15ead7b43 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md @@ -20,6 +20,7 @@ Host 在 Workspace entity 上提供以下 GUI 接线: | --- | --- | | `workspace.list` | 返回持久有序的 Workspace,并过滤未通过 header 校验的 Session id | | `workspace.create({ path })` | 按 canonical path 收编已有目录;由 basename 派生的显示名可以重复 | +| `workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` | 在持久注册表顺序内移动一个 Workspace,并返回完整的已提交顺序 | | `workspace.delete({ workspaceId })` | 移除 Workspace 注册记录,同时保留目录和会话日志;相关 Session 进入 Ungrouped | | `session.create({ workspaceId, sessionId? })` | 从 Workspace 解析 cwd,以可选预分配 id 幂等创建 Session 并 attach | | `session.create({ cwd })` | 保留给非 Workspace 调用方,创建 Ungrouped Session | @@ -49,7 +50,7 @@ Session 自己持有首条输入并驱动一条内部流水线:必要时以预 完全没有 Workspace 时,页面创建默认名为 `workspace` 的前端 Workspace 对象和指向它的前端 Session。两者不写 Host,composer 始终可输入;首次发送才依次 materialize Workspace、attach Session、发送消息。 -顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时使用最近 Workspace,没有真实 Workspace 时使用 Workspace Intent。Workspace picker 的单一 Add workspace 动作(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md);本决策做出时是 Use an existing folder 与按名称创建两个动作)会在用户确认目录时立即创建真实 Workspace,再把前端 Session 定位到该 Workspace;即使用户不发送消息,显式创建的空 Workspace 也保留。 +顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时先使用当前 Session 所属 Workspace,再使用最近 Workspace;没有真实 Workspace 时进入空白 New Session 页面。Workspace picker 的单一 Add workspace 动作(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md);本决策做出时是 Use an existing folder 与按名称创建两个动作)会在用户确认目录时立即创建真实 Workspace,再把前端 Session 定位到该 Workspace;即使用户不发送消息,显式创建的空 Workspace 也保留。 新建 Workspace 的显示名取自其所在目录。不同 canonical path 可以拥有相同的 basename 派生显示名(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md));显式的重命名操作仍保留显示名重名检查。跨 Workspace 移动 Session、从 Ungrouped 手动收编以及分别输入显示名和目录名仍不在此动线范围内。 @@ -67,11 +68,11 @@ RPC 响应丢失、Host frame 先于 completion 和 completion 先于 Host frame ### Sidebar 与排序 -Workspace 组严格使用 Host 返回的持久顺序。Bootstrap 一次性确定历史顺序,显式创建的新 Workspace 放在首位;Session 活跃不会移动 Workspace 组。 +Workspace 组使用 Host 返回的持久顺序。Bootstrap 一次性确定历史顺序,显式创建的新 Workspace 放在首位,`workspace.insertBefore` 则持久应用用户拖拽顺序;Session 活跃不会移动 Workspace 组。 -组内严格使用 `Workspace.sessionIds`。新 attach 的 Session 放在首位,后续某个 Session 活跃时 Host 只前移该 id 并持久化。Client 不在 Session list 到达后按时间整体重排,因此不会先显示一套 Workspace 顺序再因 hydration 瞬间跳动。 +Host 记账保持手动的 `Workspace.sessionIds` 顺序:新 attach 的 Session 放在首位,活动不会改动该顺序。分组浏览器可以改选浏览器本地的最近更新视图;当 Session 的 `updatedAt` 增大时该视图会把它移到首位,同时仍允许手动调整。每个打开的 Workspace 默认显示五条 Session,用户可临时展开其余条目。持久 Workspace 重排序和浏览器本地 Session 顺序见 [Workspace 侧边栏顺序与折叠](2026-08-11-workspace-sidebar-order-and-folding.md)。 -前端 Session Intent 只有在目标是真实 Workspace 时才作为 「New session」 行显示,并临时计入该组 Session 数量;目标是 Workspace Intent 时,Workspace 与 Session 都不进入 sidebar。Intent 发布后由同一预分配 id 对应的真实行接替,刷新后 Intent 行和临时计数一起消失。搜索模式不保存或筛选 Intent 行。 +当前空白 Session 会显示为一条「New session」行,但不显示数量、时间标签或行菜单;其他空白 Session 保持隐藏,并可由对应 Workspace 复用。搜索会排除空白行。 无法归入任何 Workspace 的真实 Session 进入 Ungrouped。Host `session-added` 与 `workspace-changed` 可以任意顺序到达,列表合并不依赖 frame 顺序。 @@ -105,15 +106,15 @@ Sidebar 与 conversation empty hero 通过 slot 获得标准化动作:`startSe - 前端 Session 与 Workspace 在 materialize 前后保持对象身份,输入、错误、焦点和 sidebar 投影始终来自对象层。 - 首发按 Workspace、Session、提示词顺序推进,各成功阶段不回滚,输入在提示词被接受前不丢失,创建重试使用同一 SessionId。 - Workspace list 只读取 header 完成一次可重入 bootstrap;initialized 的空 registry 重启不重复初始化,成员读取同时校验索引与 canonical cwd。 -- 初始默认目标只在两份基线 ready 后确定一次;Workspace 组不因 hydration 或 Session 活跃整体重排,单个活跃 Session 只前移自身。 -- 真实 Workspace 下的前端 Session 临时计入 sidebar 数量,Workspace Intent 保持隐藏,发布与刷新都不会留下重复行或重复计数。 +- 初始默认目标只在两份基线 ready 后确定一次;Workspace 组不因 hydration 或 Session 活跃重排,显式 Workspace 拖拽顺序在重连后仍然保持。 +- 当前空白 Session 可显示为唯一的 New Session 行,同时不暴露其他可复用空白会话,也不显示 Session 数量。 - UI 与 Host 会将 canonical path 不同但 basename 相同的目录接纳为独立 Workspace,而显式的重命名操作会拒绝重复显示名;cwd-only Session、无效历史 cwd 和未 attach Session 保持 Ungrouped。 - 经确认的 Workspace 删除只移除注册记录,保留当前 Session、目录、文件和会话日志,并在刷新后保持该状态;包级测试固定一元响应/帧/基线竞态和失败回滚行为。 - keyless runnable snapshot 覆盖零态、显式创建和首次发送;包级测试覆盖 bootstrap、成员校验、排序、幂等、失败恢复及任意 frame 顺序。 ## Consequences -- SessionHeader 不记录最后活跃时间,历史 bootstrap 只能按 `createdAt` 初始化;此后由真实 Session 活跃事件逐项前移。 +- SessionHeader 不记录最后活跃时间,历史 bootstrap 只能按 `createdAt` 初始化 Host 手动顺序;浏览器可选的最近更新视图在 hydration 后从 Session 摘要开始建立。 - 历史 cwd 缺失、目录无效或 realpath 失败的 Session 留在 Ungrouped;本期没有手动收编入口。 - 页面刷新会丢弃未 materialize 的 Workspace/Session Intent 和尚未被 Host 接受的输入,这是 page-local 约定。 - 显式 Create Workspace 立即落盘,用户不发送就离开也会留下空 Workspace。 diff --git a/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml index 4b44acecd9..2c276afafe 100644 --- a/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md -2026-08-10-durable-workflow-runs-in-chat.md: 791a81e9e304a11f45557197ac1f97184132ccab -2026-08-10-durable-workflow-runs-in-chat.zh.md: e6c87f61a144cebc0282055c8ae315d9068616fd +2026-08-10-durable-workflow-runs-in-chat.md: 817fd4debd93a4768904e3934456ebdd4bdaa896 +2026-08-10-durable-workflow-runs-in-chat.zh.md: 7b09708d94783de5aff9a9fd59757120c661775a diff --git a/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md index 791a81e9e3..817fd4debd 100644 --- a/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md +++ b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md @@ -20,7 +20,7 @@ The workflow package exposes browser-safe run and observation vocabulary through `ui-workflow-run` registers one `workflow-run` Conversation Definition and one keyed Chat renderer. Every event independently yields the same `runId`; run-start initializes State, later events update it in log order, and an update-only history tail remains pending until prepend supplies the unique start. The final node keeps the engine-owned key and anchors at run-start, placing it after the original tool call while preserving one React parent from running through terminal state. -The renderer gives each level a distinct visual responsibility. The run uses a 32-pixel module-platform background row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column. Phases exist only when a member actually starts and group by the exact phase string; an omitted phase and the empty string retain distinct identities and localized names. Member settlement changes status without removing or reordering the member. A closed Turn or Step turns missing run or member endings into interrupted presentation; a durable ending remains authoritative when present. +The renderer gives each level a distinct visual responsibility. The run uses a 32-pixel module-platform background row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column. Phases exist only when a member actually starts and group by the exact phase string; an omitted phase and the empty string retain distinct identities and localized names. Member settlement changes status without removing or reordering the member. A closed Turn or Step turns missing run or member endings into interrupted presentation; a durable ending remains authoritative when present. [Status-driven workflow disclosure](2026-08-11-workflow-run-status-driven-disclosure.md) owns which run and phase content remains visible as those facts change. Navigation is derived from two current authorities rather than persisted. A member row is interactive only while its durable member state is running and the current ordinary Session list contains the same id with `origin: 'subagent'`, `parentId` equal to the displayed parent, and `running: true`. Underlined member text is the only visible affordance; keyboard focus draws a two-pixel business-primary ring around the name area, and the fixed status label remains the lifecycle word rather than an action instruction. The renderer invokes only the injected ordinary `sessions.open(id)` callback. Addressed-only, remote, wrong-parent, and terminal members remain visible but static. @@ -42,4 +42,4 @@ Package tests cover top-level and nested eligibility, zero-member and concurrent ## Consequences -Workflow progress survives refresh and process recovery in the same log as its parent conversation, while execution ownership remains with the workflow run holder and the original tool card remains unchanged. The durable protocol adds four small events and one package-owned invariant; first-write failure intentionally sacrifices later observation rather than workflow correctness. Browser State is derived per loaded window, disclosure choices remain local, and navigation can disappear as list facts change. The design shows only actual runtime members and statuses, giving up static graph visualization, outputs, logs, controls, and terminal-member opening. +Workflow progress survives refresh and process recovery in the same log as its parent conversation, while execution ownership remains with the workflow run holder and the original tool card remains unchanged. The durable protocol adds four small events and one package-owned invariant; first-write failure intentionally sacrifices later observation rather than workflow correctness. Browser State is derived per loaded window, the status-driven disclosure lifecycle keeps review choices local, and navigation can disappear as list facts change. The design shows only actual runtime members and statuses, giving up static graph visualization, outputs, logs, controls, and terminal-member opening. diff --git a/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md index e6c87f61a1..7b09708d94 100644 --- a/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md @@ -20,7 +20,7 @@ workflow 包通过 `@deepseek-ai/dsh-workflow/types` 提供浏览器安全的运 `ui-workflow-run` 注册一个 `workflow-run` Conversation Definition 和一个 keyed Chat renderer。每条事件都能独立给出同一 `runId`;run-start 初始化 State,后续事件按日志顺序更新;只有 update 的历史尾页会保持 pending,直到 prepend 补入唯一 start。最终节点保留引擎拥有的 key,并以 run-start 锚定在原工具调用之后,从运行中到终态始终保留同一个 React 父级。 -renderer 为每一层分配不同视觉职责。运行使用 32 像素 module-platform 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。阶段只在成员真正开始时出现,并按精确阶段字符串分组;字段缺省与空字符串保留不同身份和本地化名称。成员结算只改变状态,不删除或重排成员。所属 Turn 或 Step 关闭时,缺少运行或成员终点会显示为已中断;存在持久终点时仍以它为权威。 +renderer 为每一层分配不同视觉职责。运行使用 32 像素 module-platform 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。阶段只在成员真正开始时出现,并按精确阶段字符串分组;字段缺省与空字符串保留不同身份和本地化名称。成员结算只改变状态,不删除或重排成员。所属 Turn 或 Step 关闭时,缺少运行或成员终点会显示为已中断;存在持久终点时仍以它为权威。[状态驱动的工作流 disclosure](2026-08-11-workflow-run-status-driven-disclosure.md)拥有这些事实变化时运行与阶段内容的可见性。 导航从两个当前权威派生,不写入持久记录。只有持久成员状态仍为运行中,且当前普通 Session 列表包含同一 id、`origin: 'subagent'`、`parentId` 等于当前父 Session、`running: true` 时,成员行才可交互。带下划线的成员文字是唯一可见提示;键盘聚焦时,名称区显示 2 像素 business-primary 焦点环,固定状态列继续只表达生命周期,而不写动作说明。renderer 只调用注入的普通 `sessions.open(id)` 回调。仅地址化、远程、父级不符或终态成员继续可见,但保持静态。 @@ -42,4 +42,4 @@ renderer 为每一层分配不同视觉职责。运行使用 32 像素 module-pl ## 后果 -工作流进度与父对话保存在同一日志中,能跨刷新与进程恢复;执行所有权仍属于工作流 run holder,原工具卡保持不变。持久协议增加四类小事件和一个包所有的 invariant;首次写入失败会刻意牺牲后续观察,而不是牺牲工作流正确性。浏览器 State 按已加载窗口派生,disclosure 选择保持本地,导航会随列表事实消失。设计只展示真实运行成员与状态,并放弃静态图、输出、日志、控制操作和终态成员打开。 +工作流进度与父对话保存在同一日志中,能跨刷新与进程恢复;执行所有权仍属于工作流 run holder,原工具卡保持不变。持久协议增加四类小事件和一个包所有的 invariant;首次写入失败会刻意牺牲后续观察,而不是牺牲工作流正确性。浏览器 State 按已加载窗口派生,状态驱动的 disclosure 生命周期把复盘选择留在本地,导航会随列表事实消失。设计只展示真实运行成员与状态,并放弃静态图、输出、日志、控制操作和终态成员打开。 diff --git a/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.i18n.yaml new file mode 100644 index 0000000000..1f7ecb98d3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.md +2026-08-11-workflow-run-status-driven-disclosure.md: 2f452d25a8922bb6c275419af55e8af155dd2781 +2026-08-11-workflow-run-status-driven-disclosure.zh.md: 12cc106fea274a1681ee5615906ae6df266d567b diff --git a/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.md b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.md new file mode 100644 index 0000000000..2f452d25a8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.md @@ -0,0 +1,43 @@ +# Agent Note: Status-driven disclosure for workflow runs + +Status: implemented + +English | [中文](2026-08-11-workflow-run-status-driven-disclosure.zh.md) + +## Problem + +A durable workflow Chat node updates in place from its running prefix to a terminal record. A disclosure choice initialized only at mount can hide a newly running phase, leave completed work occupying the conversation, or bury a failed, cancelled, or interrupted member behind two collapsed levels. Making openness a pure function of completion avoids those failures but also prevents users from reopening clean history for review. + +The renderer already receives every required lifecycle fact from the workflow Conversation Node. Visibility therefore needs a component-local lifecycle that gives current execution and attention states priority without adding another durable fact or taking ownership of workflow outcomes. + +## Decision + +Each phase derives one visibility requirement from its current members. A running, failed, cancelled, or interrupted member forces that phase open; a phase whose members are all completed is clean. The workflow forces itself open when its own status requires attention or any phase is forced open, so an abnormal member remains visible even when the workflow outcome is recorded as completed. A completed sibling phase remains independently collapsible. + +A forced-open level renders as an expanded static row. It exposes no button role, focus target, keyboard toggle, or `aria-expanded` value because collapsing cannot change the result. This keeps the visual hierarchy and status summaries while making the interaction promise match the available action. + +A clean level mounts an ordinary controlled disclosure in the closed state. Its local choice survives rerenders for the same continuous clean interval. New running or abnormal data replaces that manual interval with forced expansion; the next transition back to clean mounts a fresh closed disclosure, which produces one automatic fold per activity cycle. Closing the workflow naturally unmounts its phase controls, and a Session remount reconstructs every level from the current durable status rather than restoring an earlier choice. + +For example, a running workflow exposes its active phase and member without clicks. When that phase completes, only the phase folds while the workflow remains open; when the workflow and every phase complete, the workflow also folds. The user can then reopen both levels for review. If another member starts under the same phase key, both affected levels immediately return to forced expansion and fold again only after the new activity completes. + +The renderer owns only this visibility lifecycle. It does not add Session events, stores, settings, acknowledgement state, timers, focus movement, automatic scrolling, or cross-remount persistence. It does not change workflow status derivation, phase grouping, member order, navigation eligibility, copy, or the shared `DisclosureRow` API. Shared `data-expandable` styling owns pointer cursors, so forced-open static rows do not advertise an unavailable action. An interrupted durable prefix remains an attention state and therefore stays visible until the underlying facts change. + +## Verification + +Component tests drive the same keyed workflow and phase through running, clean completion, manual review, renewed activity, repeated clean completion, zero-member completion, and each abnormal status. They also verify abnormal-member propagation, clean-sibling independence, mouse and keyboard review, continuous-clean choice retention, and the absence of false button and ARIA semantics while expansion is mandatory. + +The shipped Web replay observes the real workflow, worker, Session log, browser plugin graph, and child navigation. It requires the live workflow and active phase to be visible without disclosure controls, the normally settled workflow and phase to fold, manual review to retain the terminal member without navigation, and a reload to reconstruct the folded history from durable facts. + +## Alternatives considered + +**Keep one manual state initialized from the first render.** Rejected because later lifecycle updates cannot reopen newly active or abnormal content and cannot fold normally settled work. + +**Derive `open` directly from whether a level is clean.** Rejected because completed history would remain permanently closed and could not be reopened for review. + +**Persist expansion, acknowledgement, or read state.** Rejected because current lifecycle facts already determine mandatory visibility, while review choice belongs only to the mounted presentation. Persistence would add a second state owner and require semantics for stale choices, abnormal acknowledgement, replay, and synchronization that the user result does not need. + +## Consequences + +Workflow records expose current work and abnormal outcomes without preparatory clicks, then reclaim conversation space after normal completion without sacrificing review. Interaction semantics remain truthful during automatic control, and the same durable record produces the same initial state during live rendering, refresh, and history reconstruction. + +The trade-off is deliberate local reset behavior. A phase choice disappears when its parent workflow closes or the component unmounts, and abnormal records cannot be manually hidden because the product has no acknowledgement state. Supporting either behavior later requires a separate ownership and persistence decision rather than extending this local lifecycle implicitly. diff --git a/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.zh.md b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.zh.md new file mode 100644 index 0000000000..12cc106fea --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-workflow-run-status-driven-disclosure.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 工作流运行的状态驱动 disclosure + +Status: implemented + +[English](2026-08-11-workflow-run-status-driven-disclosure.md) | 中文 + +## 问题 + +持久工作流 Chat 节点会在同一位置从运行前缀更新为终态记录。只在挂载时初始化的 disclosure 选择可能隐藏新开始运行的阶段,让已完成工作继续占据对话空间,或者把失败、已取消或已中断成员埋在两层折叠内容之后。若只把开合状态作为完成状态的纯派生结果,虽然能避免这些问题,却也会阻止用户重新打开干净历史进行复盘。 + +renderer 已经从工作流 Conversation Node 收到全部所需生命周期事实。因此,可见性需要一个组件本地生命周期:让当前执行与需注意状态优先,同时不增加另一项持久事实,也不取得工作流结果的所有权。 + +## 决策 + +每个阶段从当前成员派生一项可见性要求。存在运行中、失败、已取消或已中断成员时,该阶段强制展开;全部成员均已完成时,该阶段处于干净状态。工作流自身状态需要注意或任一阶段强制展开时,工作流也强制展开,因此即使工作流结果记录为已完成,异常成员仍保持可见。已完成的兄弟阶段继续可以独立折叠。 + +强制展开层级渲染为静态展开行。它不提供按钮 role、焦点目标、键盘切换或 `aria-expanded` 值,因为折叠操作无法改变结果。这样既保留视觉层级与状态摘要,也让交互承诺与实际可执行动作一致。 + +干净层级会以关闭状态挂载普通受控 disclosure。它的本地选择在同一段连续干净状态的 rerender 中保持。新的运行中或异常数据会用强制展开替代该手动区间;下一次回到干净状态时会挂载新的关闭 disclosure,从而让每个活动周期只自动折叠一次。关闭工作流会自然卸载其阶段控件;Session remount 会从当前持久状态重建每个层级,而不恢复更早的选择。 + +例如,运行中的工作流无需点击即可展示活跃阶段与成员。该阶段完成时,只有阶段折叠,工作流继续展开;工作流自身和全部阶段均完成时,工作流也会折叠。用户随后可以重新打开两个层级复盘。若同一阶段 key 下又开始新成员,受影响的两个层级会立即恢复强制展开,并且只在新活动完成后再次折叠。 + +renderer 只拥有这项可见性生命周期。它不增加 Session 事件、store、设置、确认状态、计时器、焦点迁移、自动滚动或跨 remount 持久化。它不改变工作流状态派生、阶段分组、成员顺序、导航准入、文案或共享 `DisclosureRow` API。pointer 光标由共享的 `data-expandable` 样式拥有,因此强制展开的静态行不会提示无法执行的操作。持久记录中的中断前缀仍属于需注意状态,因此在底层事实改变前始终可见。 + +## 验证 + +组件测试驱动同一个 keyed 工作流与阶段依次经过运行、干净完成、手动复盘、新活动、再次干净完成、零成员完成以及每种异常状态。测试还验证异常成员向上展开、干净兄弟阶段独立、鼠标和键盘复盘、连续干净状态中的选择保持,以及强制展开时不存在虚假按钮和 ARIA 语义。 + +shipped Web 回放观察真实工作流、worker、Session 日志、浏览器插件图和子级导航。它要求实时工作流与活跃阶段无需 disclosure 控件即可见,正常结算的工作流与阶段会折叠,手动复盘仍能看到不再可导航的终态成员,并且刷新会从持久事实重建折叠历史。 + +## 曾考虑的替代方案 + +**保留一项从首次渲染初始化的手动状态。** 拒绝,因为后续生命周期更新无法重新打开新活动或异常内容,也无法折叠正常结算的工作。 + +**只根据层级是否干净来派生 `open`。** 拒绝,因为已完成历史会永久保持关闭,无法重新打开复盘。 + +**持久化展开、确认或已读状态。** 拒绝,因为当前生命周期事实已经决定强制可见性,而复盘选择只属于已挂载的展示层。持久化会增加第二个状态归属方,并要求定义陈旧选择、异常确认、回放和同步语义,而用户结果不需要这些机制。 + +## 后果 + +工作流记录无需预备点击即可展示当前工作与异常结果,并在正常完成后回收对话空间,同时不牺牲复盘能力。自动控制期间的交互语义保持真实,同一份持久记录在实时渲染、刷新和历史重建时得到相同初始状态。 + +代价是有意保留的本地重置行为。父工作流关闭或组件卸载时,阶段选择会消失;由于产品没有确认状态,异常记录不能手动隐藏。以后若要支持任一行为,需要单独决定所有权与持久化,而不能隐式扩展这项本地生命周期。 diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml new file mode 100644 index 0000000000..fcad94d796 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md +2026-08-11-workspace-sidebar-order-and-folding.md: 3a88a61ca25550f1ad803a79e171ae2a7b8d4820 +2026-08-11-workspace-sidebar-order-and-folding.zh.md: e3e710bb9f38bcefcc9eeb50983c866ec5bc2619 diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md new file mode 100644 index 0000000000..3a88a61ca2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md @@ -0,0 +1,56 @@ +# Agent Note: Workspace Sidebar Order and Folding + +Status: implemented + +English | [中文](2026-08-11-workspace-sidebar-order-and-folding.zh.md) + +## Problem + +A Workspace with many Sessions can consume the entire sidebar and push other Workspaces out of reach. A compact list needs a bounded default while preserving an explicit route to every Session. The sidebar also needs an activity-oriented order, but `WorkspaceView.sessionIds` is the durable manual account and must not be rewritten by Session activity. + +Workspace groups themselves had no user-controlled durable order. Browser-native drag additionally rejects a drop released outside the list and animates the row back even when the application still has a valid insertion marker. Expanded Workspace sections make header-only hit testing ambiguous because the visual boundary between two groups does not match either header's midpoint. + +## Decision + +### Workspace order + +The Workspace registry owns a durable `workspaceIds` order and exposes `insertBefore(id, beforeId?)` with DOM `insertBefore` semantics. The Host RPC `workspace.insertBefore` returns the complete committed order, and a pure order mutation emits `host/workspace-order-changed` with the same complete order. Unknown source or anchor ids reject as `workspace-not-found`; self-anchored and already-positioned moves do not write. + +The client installs a Workspace drag optimistically. Request and frame generations ensure that only the latest unary echo can replace local order and that a newer Host frame outranks an older response; a latest rejected request restores the last complete order accepted from a Host baseline, frame, or current unary echo. Every successful list baseline restores Host order so reconnects adopt durable changes made elsewhere. + +### Session folding and view order + +Each Workspace persists one browser-local open state: closed means zero Session rows and open means up to five. When more Sessions exist, **Show more** reveals the remainder only for the current mount; closing the whole Workspace clears this transient expansion, so reopening returns to five. The current Session's group opens automatically only when the user has not already stored an explicit state for that Workspace. Creating a Session from a Workspace row opens the target group before starting the Session, keeping the new row visible when state propagation completes. After a ready Workspace baseline changes, the browser removes expansion, order, and observed-timestamp records for ids absent from that baseline while retaining the Ungrouped and flat-list accounts. + +The combined view menu offers **Manual** and **Last updated** in grouped and flat presentation, with one browser-local persisted order per account. A real Workspace initializes from `WorkspaceView.sessionIds`; Ungrouped and the cross-Workspace flat list initialize from recency and have no Host Session account. Entering Last updated performs one complete recency sort; a later user prompt or steer promotes that Session once, and dragging may edit the resulting order. Returning to Manual preserves the current order and only disables later activity promotion. Manual-mode drags for a real Workspace also write the Host Session account, while Ungrouped and flat-list drags and activity promotion remain browser-local. Flat rows omit an empty leading status slot because they have no parent hierarchy, while a visible status retains its slot. + +### Drag and compact chrome + +Workspace hit testing uses the complete rendered group section, including visible Session rows. One insertion boundary is shared by the preceding group's lower half and the following group's upper half, and the indicator is an absolutely positioned line with a joined right-facing chevron that does not affect layout. A tree-body overlay draws the first boundary at the same negative offset outside the scrolling clip, so the leading chevron remains visible without moving the list. During a Workspace or Session drag, document-level `dragover` and `drop` handlers accept the native operation; if release occurs outside the Workspace list, `dragend` commits the last valid marker. + +Search is a header action while collapsed and expands across the title and trailing actions. An outside click collapses a query that is empty after trimming but retains a non-empty query. Compact Workspace and Session rows, a 24px bottom fade, and the absence of per-Workspace Session counts preserve vertical space without removing navigation affordances. + +## Alternatives considered + +**Write every activity promotion into `Workspace.sessionIds`.** A browser presentation preference would overwrite the shared Host account whenever a user submits a prompt. + +**Keep independent Manual and Last updated orders.** Switching modes would replace the visible list with stale positions from the other order, even though choosing Manual only means that later activity stops moving rows. + +**Always show every Session in an open Workspace.** One large Workspace would continue to crowd out the rest, and remembering only the whole-group open state would not bound its height. + +**Persist the expanded-remainder state.** A Workspace reopened much later could unexpectedly occupy the full sidebar. Only the zero-or-five state represents a stable navigation preference; revealing the remainder is a local inspection. + +**Use numeric drop indices or header-only hit testing.** Indices drift when rows change during a drag, while header midpoints disagree with the visible boundary when a Workspace is expanded. Anchor ids and full-section geometry remain stable under both conditions. + +**Let the browser reject an outside release.** The application would commit the last valid marker while the browser displays a rejected-drop animation, presenting contradictory feedback. + +## Consequences + +- Workspace order is durable and shared through the Host, while grouping, open state, per-account Session view order, and query state remain browser-local presentation preferences. Ungrouped and the flat list support the same drag and promotion rules, but their orders are browser-local because neither has one Workspace account. +- Last updated performs a complete recency sort on entry, then preserves manual adjustments until a user prompt or steer advances one Session and moves it to the front. Returning to Manual preserves every current position. +- Opening a Workspace never shows more than five Sessions without an explicit **Show more** gesture, and closing it resets only that transient gesture. +- The Host Session account retains the manual-order meaning established by [Session List Browsing and Manual Workspace Order](2026-07-25-session-list-browsing-and-manual-order.md). + +## Testing + +Domain and Host tests cover durable Workspace moves, no-op and invalid anchors, restart recovery, full-order RPC responses, order frames, and one Workspace snapshot per Host-stream baseline. Runtime tests cover optimistic order, frame/response precedence, overlapping rejection rollback to Host-confirmed order, reconnect baselines, and New Session target priority. UI tests cover five-row folding, transient expansion reset, pruning persisted state after Workspace removal, order-preserving mode switches, one-time recent-update promotion, browser-local Ungrouped and flat-list drag persistence, hierarchy-free flat-row leading spacing, selected view indicators, expanded-section Workspace hit testing, an unclipped first insertion boundary, outside-list Workspace and Session drops, search collapse rules, and compact CSS dimensions. diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md new file mode 100644 index 0000000000..e3e710bb9f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md @@ -0,0 +1,56 @@ +# Agent Note: Workspace 侧边栏顺序与折叠 + +Status: implemented + +[English](2026-08-11-workspace-sidebar-order-and-folding.md) | 中文 + +## 问题 + +Session 很多的 Workspace 会占满整个侧边栏,把其他 Workspace 挤出可见范围。紧凑列表需要有界的默认高度,同时仍要提供到达每条 Session 的明确入口。侧边栏还需要面向活动时间的顺序,但 `WorkspaceView.sessionIds` 是持久的手动记账,不能被 Session 活动改写。 + +Workspace 分组本身没有用户可控的持久顺序。浏览器原生拖拽还会把列表外松手判为拒绝,并把行弹回原位,即使应用仍持有有效插入标记。Workspace 展开后,若只按组头命中,两个分组之间的视觉边界也不再等于任一组头的中点。 + +## 决策 + +### Workspace 顺序 + +Workspace 注册表持有持久 `workspaceIds` 顺序,并提供采用 DOM `insertBefore` 语义的 `insertBefore(id, beforeId?)`。Host RPC `workspace.insertBefore` 返回完整的已提交顺序;单纯顺序变更通过 `host/workspace-order-changed` 推送同一份完整顺序。未知来源或锚点 id 以 `workspace-not-found` 拒绝;以自身为锚点或移动到当前位置不会写入。 + +客户端对 Workspace 拖拽进行乐观安装。请求代次与帧代次保证只有最新一元回声可以替换本地顺序,且更新的 Host 帧优先于旧响应;最新请求被拒时会恢复最近一份由 Host 基线、帧或当前一元回声确认的完整顺序。每次成功的列表基线都会恢复 Host 顺序,因此重连会接纳其他位置提交的持久变更。 + +### Session 折叠与视图顺序 + +每个 Workspace 持久化一项浏览器本地打开状态:关闭表示零条 Session 行,打开表示最多五条。存在更多 Session 时,**展开其余**只在当前挂载期间显示剩余项;关闭整个 Workspace 会清除此临时展开,因此重新打开时恢复为五条。只有在用户尚未为该 Workspace 存储明确状态时,当前 Session 所在分组才会自动打开。从 Workspace 行创建 Session 时会在启动 Session 前打开目标分组,使状态传播完成后新行保持可见。就绪的 Workspace 基线发生变化后,浏览器会移除基线中不存在 id 的展开状态、顺序和已观察时间戳记录,同时保留 Ungrouped 和单列表记账。 + +组合视图菜单在分组和单列表呈现中都提供**手动排序**和**最近更新**,每个记账各自持有一份浏览器本地持久顺序。真实 Workspace 从 `WorkspaceView.sessionIds` 初始化;Ungrouped 和跨 Workspace 的单列表从最近更新时间顺序初始化,且没有 Host Session 记账。进入最近更新时会执行一次完整的时间排序;后续 user prompt 或 steer 会将对应 Session 置顶一次,拖拽仍可编辑所得顺序。返回手动排序会保留当前顺序,只停用后续活动置顶。真实 Workspace 在手动模式下的拖拽还会写入 Host Session 记账,而 Ungrouped 和单列表的拖拽与活动置顶保留在浏览器本地。单列表没有父级层次,因此不显示空的左侧状态槽;存在可见状态时仍保留该槽。 + +### 拖拽与紧凑界面 + +Workspace 命中测试使用完整渲染分组区段,包括可见 Session 行。前一分组的下半部与后一分组的上半部共享同一条插入边界,指示器是一条带有相连右向尖角且不影响布局的绝对定位横线。树主体覆盖层会在滚动裁切区外以相同的负偏移绘制第一条边界,因此左侧尖角保持可见,列表位置也不会改变。Workspace 或 Session 拖拽期间,文档级 `dragover` 与 `drop` 处理器会接受原生操作;若在 Workspace 列表外松手,`dragend` 会提交最后一个有效标记。 + +搜索在折叠时是区头操作,展开后占据标题与尾部操作的空间。查询经清除首尾空白后为空时,点击外部会收起搜索;非空查询则会保留。紧凑的 Workspace 与 Session 行、24px 底部渐隐以及取消每个 Workspace 的 Session 数量共同节省纵向空间,同时保留导航入口。 + +## 考虑过的替代方案 + +**把每次活动置顶写入 `Workspace.sessionIds`。** 浏览器呈现偏好会在用户每次提交提示词时覆盖共享的 Host 记账。 + +**为手动排序和最近更新分别保留独立顺序。** 切换模式会用另一份顺序中的旧位置替换可见列表,而选择手动排序只表示后续活动不再移动条目。 + +**打开 Workspace 时始终显示全部 Session。** 大型 Workspace 仍会挤占其他分组;只记忆整个分组的打开状态无法限制其高度。 + +**持久化展开剩余状态。** 很久以后重新打开 Workspace 时,它可能意外占满侧边栏。只有零条或五条状态属于稳定导航偏好;显示剩余项只是一次本地查看。 + +**使用数字下标或只按组头命中拖拽。** 拖拽期间行发生变化会使下标漂移;Workspace 展开时,组头中点与可见边界不一致。锚点 id 与完整区段几何在两种情况下都保持稳定。 + +**让浏览器拒绝列表外松手。** 应用会提交最后一个有效标记,而浏览器同时播放拒绝动画,形成相互矛盾的反馈。 + +## 后果 + +- Workspace 顺序通过 Host 持久并共享;分组方式、打开状态、每个记账的 Session 视图顺序和查询状态仍是浏览器本地呈现偏好。Ungrouped 和单列表支持相同的拖拽与置顶规则,但因没有单一 Workspace 记账,其顺序只保存在浏览器本地。 +- 最近更新模式会在进入时执行完整时间排序,随后保持手动调整,直到 user prompt 或 steer 推进某条 Session 并将其置顶。返回手动排序会保留所有当前位置。 +- 未执行明确的**展开其余**手势时,打开 Workspace 最多显示五条 Session;关闭分组只重置这项临时手势。 +- Host Session 记账继续采用[会话列表浏览与 Workspace 手动排序](2026-07-25-session-list-browsing-and-manual-order.md)确立的手动顺序含义。 + +## 测试 + +领域与 Host 测试覆盖持久 Workspace 移动、无操作与无效锚点、重启恢复、完整顺序 RPC 响应、顺序帧以及每条 Host stream 基线只读取一份 Workspace 快照。运行时测试覆盖乐观顺序、帧/响应优先级、重叠拒绝后恢复 Host 已确认顺序、重连基线以及 New Session 目标优先级。UI 测试覆盖五行折叠、临时展开重置、Workspace 移除后清理持久状态、保持顺序的模式切换、一次性最近更新置顶、浏览器本地 Ungrouped 与单列表拖拽持久化、无层级单列表行左侧间距、当前视图标记、展开区段的 Workspace 命中、未裁切的第一条插入边界、列表外 Workspace 与 Session 松手、搜索收起规则和紧凑 CSS 尺寸。 diff --git a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml index ba3b6a970d..7b251c2470 100644 --- a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md -2026-07-04-doc-tiers-and-budgets.md: e7b3421d09a1ae5ab9a9373e8040832c1b0d4b97 -2026-07-04-doc-tiers-and-budgets.zh.md: 3bc04ae73a4d8c9c005e154a236772fc1845389e +2026-07-04-doc-tiers-and-budgets.md: 3f263864b9b6ee9479d1133b908617f10073dd66 +2026-07-04-doc-tiers-and-budgets.zh.md: 63b0b2945e1ff3e3fdf6af3cddb80cf44cf448ee diff --git a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md index e7b3421d09..3f263864b9 100644 --- a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md +++ b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md @@ -12,6 +12,7 @@ Standing docs accumulated repeated rules, retold incidents, duplicated package m - **Structure follows the documentation tree.** [docs/AGENTS.md](../../../../docs/AGENTS.md) is the documentation standard: a document owns detail about its subject, summarizes only the purpose, responsibility, and high-level behavior of direct children, and links to deeper owners. [Agent Notes](../../README.md) remain outside this structural contract. Every human-facing document is a tutorial with an ordered outcome or a reference with an explicit lookup scope; a [postmortem](../../../../docs/postmortem/README.md) is an incident-scoped reference whose chronology records evidence. Tutorials introduce concepts in prerequisite order for the reader's starting knowledge. - **A tier taxonomy with one home per fact.** The standard assigns every Markdown tier one job, forbids restating a fact outside its home tier, and carries the slop checklist used when writing or reviewing any doc. +- **One product onboarding path.** The root README owns the recommended package-run path, the source-run alternative, and compact `dsh plugin --profile` usage. The published user guide starts with tasks inside the running Web UI, then links to distinct tutorials or reference owners for other interfaces, plugin development, and advanced configuration instead of repeating Web startup. - **A narrow, hard budget gate.** [scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) joins `doc-sync`: every doc listed in [scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) must stay under its word ceiling (`wc -w` semantics, whole file), and a budgeted file that is missing fails the gate so a rename cannot silently orphan its budget. Scope is deliberately only the accretion-prone standing docs — the root and subtree `AGENTS.md` files, `architecture.md`, `packages/README.md`, and the standing policy docs they evict content into (`docs/testing.md`, `docs/defensive-patterns.md`). Reference docs, Agent Notes, and package READMEs are unbudgeted: length is legitimate there when every row is a fact, and review plus the slop checklist govern them. - **Ceilings are an enforcement frontier that ratchets.** A doc at or below its target keeps at least 5% headroom as its ceiling ratchets down; a doc above target keeps a frozen ceiling that prevents growth until it reaches the target (root `AGENTS.md` ≤ 1,600 words; `architecture.md` ≤ 1,800; subtree `AGENTS.md` ≤ 600 except `packages/AGENTS.md` ≤ 650 and `docs/AGENTS.md` ≤ 1,250; `packages/README.md` ≤ 600). When the gate goes red, relocate or condense; raise a ceiling only with explicit PR justification. - **A thin workflow skill, contracts in docs.** [.agents/skills/dsh-doc-standards](../../../skills/dsh-doc-standards/SKILL.md) carries the placement/audit/red-gate workflow and defers to the standard as its source of truth, the same split as [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) over the i18n contract. @@ -20,11 +21,13 @@ Standing docs accumulated repeated rules, retold incidents, duplicated package m - **Skill and review discipline without a gate** — rejected: the accretion above happened while the current-state rule and reviewer attention already existed; a prose rule with no mechanical backstop demonstrably does not hold here, and this repo's own [quality-gates stance](2026-06-11-quality-gates.md) says invariants worth keeping are worth encoding. - **A broad gate over every doc tier** — rejected: a blanket ceiling punishes exactly the right kind of long doc (a feature matrix or type catalog where every row is a fact) and generates per-file override churn that trains contributors to rubber-stamp raises. +- **Independent onboarding tutorials for each documentation entry point** — rejected: duplicated setup steps drift in command order, first outcome, and product identity. A short README path followed by task-focused guides keeps the transition explicit without maintaining competing tutorials. - **Housing the standard inside the skill** — rejected: contracts live in docs and workflows in skills; a standard packed into SKILL.md is invisible to an agent that edits docs without invoking the skill, and `docs/AGENTS.md` already loads as subtree instructions for anyone working under `docs/`. ## Consequences - Adding to a budgeted doc requires displacement: relocate the addition to its taxonomy home with a pointer, or condense existing prose to pay for it. Growth without pruning fails CI. - Structural review starts with ownership and document form before sentence-level editing, so lower-level detail moves to its owner instead of being polished in the wrong place. +- Readers reach a running Web UI before encountering headless execution, SDK embedding, custom profiles, or direct settings files; those interfaces remain available from their reference owners. - Budgeted docs that remain above target cannot grow; reaching the target restores the 5% working headroom. - Word count is a crude proxy accepted deliberately: it cannot judge quality, but it forces the relocation decision at exactly the moment content is being added, which is when the author has the context to place it correctly. diff --git a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md index 3bc04ae73a..63b0b2945e 100644 --- a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md +++ b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md @@ -12,6 +12,7 @@ Status: implemented - **结构遵循文档树。**[docs/AGENTS.md](../../../../docs/AGENTS.md) 是文档标准:文档负责承载其主题的详细内容,仅概述直接子项的目的、职责和高层行为,并链接到更深层内容的归属文档。[Agent Note](../../README.md) 仍不受这一结构约定约束。每份面向人的文档要么是按顺序引导读者达成结果的教程(tutorial),要么是查阅范围明确的参考文档(reference);[事故复盘(postmortem)](../../../../docs/postmortem/README.md) 是范围限定于单起事故的参考文档,其时间线记录证据。教程结合读者的起始知识,按前置依赖顺序介绍概念。 - **每项事实只归属一处的层级分类。**文档标准为每种 Markdown 层级分配单一职责,禁止在事实归属层级之外重复陈述,并包含编写或评审任何文档时使用的赘余检查清单。 +- **单一产品入门路径。**根 README 负责推荐的包运行路径、从源码运行的备选路径和简要的 `dsh plugin --profile` 用法。已发布的用户指南从运行中的 Web UI 内部任务开始,再链接到其他界面的独立教程或插件开发与进阶配置的参考文档归属处,而不会重复介绍 Web 启动步骤。 - **范围窄且严格的预算门禁。**[scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) 接入 `doc-sync`:[scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) 列出的每份文档都必须低于其词数上限(采用 `wc -w` 语义,统计整个文件);预算内文件缺失也会使门禁失败,使重命名无法悄然遗落其预算。范围刻意只涵盖容易膨胀的常设文档——根目录和子树中的 `AGENTS.md` 文件、`architecture.md`、`packages/README.md`,以及它们将内容移入的常设策略文档(`docs/testing.md`、`docs/defensive-patterns.md`)。参考文档、Agent Note 和包 README 不设预算:只要每一行都是事实,长度在这些位置就是合理的;评审和赘余检查清单负责约束它们。 - **上限是只进不退的执行红线。** 达到或低于目标的文档在上限逐步下调时保留至少 5% 的余量;高于目标的文档则维持冻结的上限,在达到目标之前不得增长(根 `AGENTS.md` ≤ 1,600 词;`architecture.md` ≤ 1,800;子树 `AGENTS.md` ≤ 600,但 `packages/AGENTS.md` ≤ 650、`docs/AGENTS.md` ≤ 1,250;`packages/README.md` ≤ 600)。门禁变红时,迁移或压缩内容;只有在 PR(Pull Request)描述中给出明确理由时才提高上限。 - **精简的工作流 skill(技能),约定归文档。**[.agents/skills/dsh-doc-standards](../../../skills/dsh-doc-standards/SKILL.md) 承载文档放置、审计和门禁失败处理工作流,并以文档标准为真源,与 [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 和 i18n 约定之间的分工相同。 @@ -20,11 +21,13 @@ Status: implemented - **仅靠 skill 和评审纪律,不设门禁**:否决。上述膨胀正是在现行规则和评审注意力已经存在的情况下发生的;一条没有自动化保障的行文规则在此处已被证明无法维持,而本仓库自身的[质量门禁立场](2026-06-11-quality-gates.md)认为值得保持的不变式就值得编码。 - **对所有文档层级全面设限**:否决。一刀切的上限恰好惩罚了那些正当的长文档(如功能矩阵或类型目录,每一行都是事实),并产生逐文件的例外变更,训练贡献者机械地批准提限。 +- **为每个文档入口维护独立入门教程**:否决。重复的设置步骤会在命令顺序、首个结果和产品定位上产生分歧。简短的 README 路径接上面向任务的指南,可明确衔接两者,且不需要维护相互竞争的教程。 - **将标准放在 skill 内部**:否决。约定归文档,工作流归 skill;如果标准被塞进 SKILL.md,那些不调用该 skill 而直接编辑文档的 agent(智能体)就看不到它,而 `docs/AGENTS.md` 已经作为子树指令被任何在 `docs/` 下工作的人加载。 ## 后果 - 向受预算约束的文档添加内容需要腾挪空间:将新增内容迁移到其分类体系归属地并留下链接,或压缩现有行文来腾出空间。只增不减会导致 CI 失败。 - 结构评审先检查归属关系和文档形式,再进行句子层面的编辑,使较低层级的细节迁移到其归属文档,而不是在错误的位置加以润色。 +- 读者会先进入可运行的 Web UI,再遇到 headless 执行、SDK 嵌入、自定义 profile 或直接 settings 文件;这些入口仍可从各自的参考文档归属处访问。 - 仍高于目标的受预算约束文档不得增长;达到目标后,将恢复 5% 的工作余量。 - 词数是一个粗糙的代理指标,这是有意接受的:它无法判断质量,但它在内容被添加的那一刻强制触发迁移决策,而那正是作者拥有足够上下文来正确放置内容的时刻。 diff --git a/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.i18n.yaml new file mode 100644 index 0000000000..97163e3a85 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.md +2026-08-11-cmdline-program-action.md: 40c4dae1d3461f25ac7f34dee7c166434e6cd24d +2026-08-11-cmdline-program-action.zh.md: 91036f1c52b60d28055935813d6698205f045422 diff --git a/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.md b/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.md new file mode 100644 index 0000000000..40c4dae1d3 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.md @@ -0,0 +1,29 @@ +# Agent Note: parseCmdline runs the program's own commander action + +Status: implemented + +English | [中文](2026-08-11-cmdline-program-action.zh.md) + +## Problem + +`dsh-cmdline`'s ([app-owned command line](../architecture/2026-08-06-app-owned-command-line.md)) `parseCmdline` carried a bespoke callback: `CmdlinePlan = (program, ctx) => T`, invoked after a successful parse inside the helper's catch so a plan's `program.error(...)` shared the help/parse-error exit path, with a type-unsound `(() => ({}) as T)` default only tests used and a `ctx` argument no plan read. The whole seam duplicated a slot commander already defines: a command's action handler runs inside `parse`, and `program.error(...)` thrown from it obeys `exitOverride` exactly like a grammar rejection. + +## Decision + +`parseCmdline(ctx, program): void` only adapts commander control flow to the launcher: it parses the immutable `cmdlineArgs` snapshot and turns help, version, parse errors, and action rejections into a `ctx.appExit` request. App code — validation commander's grammar cannot express and the `ctx.provide` of the app-owned service — lives in the program's own synchronous `.action()`, which commander runs on a successful parse and never runs on help or rejection. The `CmdlinePlan` export, its `ctx` parameter, the default plan, and the `T | undefined` return are deleted; both bundle providers publish from their action. Because the `Command` type cannot express the action precondition, `parseCmdline` reads the handler structurally (as `isCommanderError` reads commander's control-flow errors) and refuses at load a program in which no command declares an action — without the guard, a provider that forgot its action (or a stale caller still passing the deleted third argument) parses successfully, publishes nothing, and surfaces only as dependent rows pending on the absent service at settlement. The helper configures `exitOverride` and output on the whole command tree, not the root alone: commander copies those settings into a subcommand only at registration, so a root-only override would let a pre-registered subcommand's rejection call `process.exit` past `ctx.appExit`. An action must reject before it publishes; statements before its `program.error(...)` have already run. + +Verified on commander 15 before shipping: an action runs inside `parse` and its `program.error(...)` throws a `CommanderError` through `exitOverride`; help and version short-circuit before the action; excess-argument handling is identical with and without an action. + +## Alternatives considered + +- **Keeping a bespoke `resolve`/plan callback**: it existed only so app rejection could share the helper's catch, which commander's action slot already provides; a second callback seam for the same moment in the parse lifecycle is duplication. +- **Returning the parsed `Command` for the caller to read**: a post-parse `program.error(...)` in the caller escapes the helper's catch as an uncaught `CommanderError`, turning a usage rejection into a plugin load failure; every app with validation would rebuild the try/catch the helper owns. +- **Moving all validation into commander option/argument parsers**: `InvalidArgumentError` covers per-value checks, but the headless bundle rejects a joined variadic ("task must be non-blank") with its own usage message, which per-argument parsers cannot express. +- **Accepting an action-less program and relying on the settlement diagnostic**: the assembled launcher does fail loud (`pending (waiting for service: …)`), but that error names the consumers, not the misconfigured provider, and an embedding host without the settlement assertion would hang silently; the load-time guard reports the culprit program directly. +- **Replacing the `CmdlineArgs` accessor with a bare frozen `readonly string[]` service**: the maintainer keeps the accessor object as the service's named interface. + +## Consequences + +- `parseCmdline` loses its generic, callback parameter, and `undefined` sentinel; callers lose the `if (values !== undefined)` publish guard. +- An app's command is self-contained — flags, help text, validation, and the publishing effect travel together on the `Command`. +- Actions must be synchronous: the helper calls `parse`, not `parseAsync`, so a returned promise would escape the catch unobserved. diff --git a/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.zh.md b/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.zh.md new file mode 100644 index 0000000000..91036f1c52 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.zh.md @@ -0,0 +1,29 @@ +# Agent Note: parseCmdline 运行 program 自己的 commander action + +Status: implemented + +[English](2026-08-11-cmdline-program-action.md) | 中文 + +## Problem + +`dsh-cmdline`([应用自有命令行](../architecture/2026-08-06-app-owned-command-line.md))的 `parseCmdline` 曾带着一个自造的回调:`CmdlinePlan = (program, ctx) => T`,在解析成功后于该适配器的 catch 之内调用,使 plan 的 `program.error(...)` 与 help/解析错误共用同一条退出路径;它还带有只被测试使用、类型不健全的默认值 `(() => ({}) as T)`,以及没有任何 plan 读取的 `ctx` 参数。这整条接缝复制了 commander 本就定义的席位:命令的 action 处理器在 `parse` 内部运行,从中抛出的 `program.error(...)` 与语法拒绝一样遵循 `exitOverride`。 + +## Decision + +`parseCmdline(ctx, program): void` 只把 commander 的控制流适配到启动器:它解析不可变的 `cmdlineArgs` 快照,并把 help、version、解析错误与 action 的拒绝转换为一次 `ctx.appExit` 请求。应用代码——commander 语法表达不了的校验,以及应用自有服务的 `ctx.provide`——放在 program 自己的同步 `.action()` 里,commander 在解析成功时运行它,在 help 或拒绝时绝不运行。`CmdlinePlan` 导出、其 `ctx` 参数、默认 plan 与 `T | undefined` 返回值全部删除;两个组合包提供方都在各自的 action 中发布。由于 `Command` 类型无法表达 action 前置条件,`parseCmdline` 按结构读取处理器(如同 `isCommanderError` 按结构识别 commander 的控制流错误),在加载时拒绝整棵命令树中没有任何命令声明 action 的 program 并点名它——若无此守卫,漏写 action 的提供方(或仍在传已删除第三参数的陈旧调用方)会解析成功、什么也不发布,只在 settlement 时以依赖行 pending 等待缺席服务的形式浮现。该适配器在整棵命令树而非仅根命令上配置 `exitOverride` 与输出:commander 只在注册时把这些设置复制进子命令,只配置根命令会让已注册子命令的拒绝绕过 `ctx.appExit` 直接调用 `process.exit`。action 必须先拒绝后发布;写在 `program.error(...)` 之前的语句已经执行。 + +交付前已在 commander 15 上验证:action 在 `parse` 内部运行,其 `program.error(...)` 经 `exitOverride` 抛出 `CommanderError`;help 与 version 在 action 之前短路;有无 action 时的多余参数处理完全一致。 + +## Alternatives considered + +- **保留自造的 `resolve`/plan 回调**:它存在的唯一理由是让应用侧的拒绝共用适配器的 catch,而 commander 的 action 席位本就提供这一点;为解析生命周期的同一时刻再造第二条回调接缝属于重复。 +- **返回解析后的 `Command` 交调用方读取**:调用方在解析之后调用 `program.error(...)` 会以未捕获的 `CommanderError` 逃出适配器的 catch,把一次用法拒绝变成插件加载失败;每个带校验的应用都得重建适配器持有的那套 try/catch。 +- **把全部校验移进 commander 的 option/argument 解析器**:`InvalidArgumentError` 覆盖逐值检查,但 headless 组合包用自己的用法信息拒绝拼接后的可变参数("任务不得为空白"),逐参数解析器表达不了。 +- **接受没有 action 的 program,依赖 settlement 诊断**:组装好的启动器确实会大声失败(`pending (waiting for service: …)`),但那个错误点名的是消费者而非配置错误的提供方,且没有 settlement 断言的嵌入宿主会静默挂起;加载时守卫直接报出肇事的 program。 +- **用裸的冻结 `readonly string[]` 服务替换 `CmdlineArgs` 访问器**:维护者保留该访问器对象作为服务的具名接口。 + +## Consequences + +- `parseCmdline` 失去泛型、回调参数与 `undefined` 哨兵值;调用方不再需要 `if (values !== undefined)` 的发布守卫。 +- 应用的命令是自包含的——flag、help 文本、校验与发布效果一起挂在 `Command` 上。 +- action 必须是同步的:适配器调用的是 `parse` 而非 `parseAsync`,返回的 promise 会在无人观察的情况下逃出 catch。 diff --git a/BENCHMARK.md b/BENCHMARK.md index 6e8f466a1f..d5e9dc7831 100644 --- a/BENCHMARK.md +++ b/BENCHMARK.md @@ -1,3 +1,3 @@ # Running benchmarks -To run benchmark tasks with the minimal agent composition, follow [Get started with the Python SDK](docs/user/guide/python-sdk.md). The guide covers installation, running [`minimal.cordis.yml`](examples/jsonrpc-agent/minimal.cordis.yml), and isolating workspaces and session IDs between tasks. +Follow [Get started with the Python SDK](docs/user/guide/python-sdk.md) to install the SDK and run the `jsonrpc-agent` minimal variant. Use separate workspaces and session IDs for independent benchmark tasks. diff --git a/README.i18n.yaml b/README.i18n.yaml index a7f0956a0a..4d38f6e19c 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -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 README.md -README.md: 9c19dfec19cba6f1364e4f9d5734af49675d68c2 -README.zh.md: 31d83ede854e9f0dfbbba1f8ce1094d043f6d829 +README.md: 690cde099d93ea2a371b31f441030153b1aca973 +README.zh.md: 2a8046011da7c1970d1210f291973db36e379665 diff --git a/README.md b/README.md index 9c19dfec19..690cde099d 100644 --- a/README.md +++ b/README.md @@ -12,64 +12,43 @@ DeepSeek Harness is under internal testing. Features and interfaces may change. The internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group. -## Run from source +## Run -Clone this repo, complete the [dependency and API-key setup](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key), then run: +Install Node.js ^22.19 or >= 24 and pnpm 11, then run the published package: ```sh +npx @deepseek-ai/dsh web +``` + +The command initializes the Web profile and prints the Web UI URL, which is `http://127.0.0.1:3080` by default. Open it, add a DeepSeek API key under **Settings → Models**, then start a session. The invoking directory is the default workspace; try `Summarize this repository and identify its main packages.` + +Continue with the [Web UI guide](docs/user/guide/). + +### Run from source + +To run a repository checkout instead: + +```sh +git clone https://github.com/deepseek-harness/deepseek-harness.git +cd deepseek-harness +pnpm install pnpm dsh web ``` -## Use DeepSeek Harness +The last command builds the repository and opens the same Web UI path. -### Web UI +## Profiles and plugins -Start the recommended local interface from the repository root: +A profile is an ordered list of plugin bundles. The shipped `web` profile powers `dsh web`. Manage a profile with `dsh plugin --profile `, which forwards the remaining arguments to pnpm in that profile's directory: ```sh -pnpm dsh web +npx -p @deepseek-ai/dsh dsh plugin --profile web add +npx -p @deepseek-ai/dsh dsh plugin --profile web remove ``` -The command builds the repository before starting the Web UI, which is served at `http://127.0.0.1:3080` by default. +`add`, `remove`, `update`, `why`, and other pnpm commands work unchanged. The command initializes a missing profile before changing its packages and updates its bundle list from installed packages that declare `dsh.bundle`. See the [CLI reference](apps/cli/reference/README.md#plugin-management) for the exact behavior. -### Profiles - -The source CLI boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`: - -```sh -pnpm dsh --profile web # the browser UI -pnpm dsh plugin --profile tui add # install a plugin into a custom profile -pnpm dsh --profile tui # boot it -``` - -The [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands. - -### Headless - -Run one task, print the final answer, and exit: - -```sh -pnpm dsh --profile headless "summarize this workspace" -``` - -### Automation and SDKs - -From a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server: - -```sh -pnpm run demo:acp -``` - -The [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions. - -## Why DeepSeek Harness - -Built-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode. - -- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design. -- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log). -- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode). -- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md). +The [CLI reference](apps/cli/README.md) covers headless execution and custom profiles. The [Python SDK](python/README.md) and [examples](examples/README.md) cover programmatic and custom compositions. ## Community @@ -81,8 +60,6 @@ Start with the [development guide](docs/development.md) and read the [architectu For agents, follow [AGENTS.md](AGENTS.md). -DeepSeek Harness is currently in internal testing. - ## License [BSD 3-Clause](LICENSE) diff --git a/README.zh.md b/README.zh.md index 31d83ede85..2a8046011d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -12,64 +12,43 @@ DeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化 为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。 -## 从源码运行 +## 运行 -克隆本仓库,完成[依赖安装和 API 密钥配置](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key),然后运行: +安装 Node.js ^22.19 或 >= 24 和 pnpm 11,然后运行已发布的包: ```sh +npx @deepseek-ai/dsh web +``` + +该命令会初始化 Web profile 并打印 Web UI 地址,默认地址为 `http://127.0.0.1:3080`。打开该地址,在**设置 → 模型**中添加 DeepSeek API 密钥,然后启动一个会话。调用目录是默认工作区;你可以尝试输入 `Summarize this repository and identify its main packages.`。 + +下一步请阅读 [Web UI 指南](docs/user/guide/)。 + +### 从源码运行 + +如需改为运行仓库 checkout: + +```sh +git clone https://github.com/deepseek-harness/deepseek-harness.git +cd deepseek-harness +pnpm install pnpm dsh web ``` -## 使用 DeepSeek Harness +最后一条命令会构建仓库,并进入相同的 Web UI 路径。 -### Web UI +## Profile 与插件 -请从仓库根目录启动推荐的本地界面: +profile 是按顺序排列的插件 bundle 列表。随附的 `web` profile 为 `dsh web` 提供功能。使用 `dsh plugin --profile ` 管理 profile;该命令会在对应 profile 目录中将剩余参数转发给 pnpm: ```sh -pnpm dsh web +npx -p @deepseek-ai/dsh dsh plugin --profile web add +npx -p @deepseek-ai/dsh dsh plugin --profile web remove ``` -该命令会先构建仓库,再启动 Web UI。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。 +`add`、`remove`、`update`、`why` 等 pnpm 命令均可直接使用。该命令会先初始化不存在的 profile,再修改其中的包,并根据声明了 `dsh.bundle` 的已安装包更新 bundle 列表。准确行为见 [CLI 参考](apps/cli/reference/README.md#plugin-management)。 -### Profile - -源码 CLI(命令行界面)会启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层: - -```sh -pnpm dsh --profile web # the browser UI -pnpm dsh plugin --profile tui add # install a plugin into a custom profile -pnpm dsh --profile tui # boot it -``` - -profile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。 - -### Headless - -运行一项任务,打印最终答案后退出: - -```sh -pnpm dsh --profile headless "summarize this workspace" -``` - -### 自动化与 SDK - -在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器: - -```sh -pnpm run demo:acp -``` - -[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。 - -## 为什么选择 DeepSeek Harness - -内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。 - -- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。 -- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。 -- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。 -- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。 +[CLI(命令行界面)参考](apps/cli/README.md)介绍 headless 执行与自定义 profile。[Python SDK](python/README.md) 和[示例](examples/README.md)介绍程序化组合与自定义组合。 ## 社区 @@ -85,8 +64,6 @@ pnpm run demo:acp 面向 agent:遵循 [AGENTS.md](AGENTS.md)。 -DeepSeek Harness 目前处于内测阶段。 - ## 许可证 [BSD 3-Clause](LICENSE) diff --git a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md index 744d2f13c1..d897cafb7a 100644 --- a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md +++ b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md @@ -25,13 +25,13 @@ Two planes, and the choice is not about how "agent-related" something feels — A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. -Locally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. Both roots are configuration rather than fixed locations, though, and no call reports them — `authorable` says only whether a writable one exists — so take the path you actually read or edit from `list()` or `resolve()`, which is also where `copy()` reports what it just created. +Locally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. A deployment can configure other roots, so the path you read or edit comes from `list()` or `resolve()` — which is also where `copy()` reports what it just created. ## The roster service `ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step. -Read `cordis_inspect what:"api" name:"agentPresets"` for the current signatures before writing the code. The four calls this skill relies on: +Read `cordis_inspect what:"api" name:"agentPresets"` for the current signatures before writing the code. What this skill relies on: - `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent. - `read(id)` — one preset's composition text, without a file tool or a path. diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index be2a1004b4..26f612db1a 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -29,13 +29,11 @@ import { watchUserPatches, type Profile, } from '@deepseek-ai/dsh-app-boot' -import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' /** Shipped agent-preset root: beside this app's own config, in both source and built layouts. */ const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', import.meta.url)) -/** Harness-home directory holding locally authored agent presets. */ -const USER_PRESET_DIR = '.agent-presets' import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import { provideCmdline } from '@deepseek-ai/dsh-cmdline' import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts' @@ -154,16 +152,16 @@ function composeProfile( if (typeof row.id === 'string') rows.set(row.id, row) } const composedOverlays = [...overlays] - // Preset roots belong to every dsh composition that mounts the roster. + // The SHIPPED root is the part of the roster only this app can resolve: it + // sits beside this app's own config, in both the source and built layouts. + // The writable root the roster appends is `dsh-agent-presets`' own, so a + // launcher that never reaches this patch still finds a person's presets. if (rows.has('agent-presets')) { composedOverlays.push({ id: 'agent-presets', config: { ...(rows.get('agent-presets')?.config ?? {}) as Record, - roots: [ - { path: SHIPPED_PRESET_ROOT, trust: 'system' }, - { path: dshHomePath(USER_PRESET_DIR), trust: 'user' }, - ], + roots: [{ path: SHIPPED_PRESET_ROOT, trust: 'system' }], }, }) } diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index c7834c3658..c989d9ce3e 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -227,8 +227,8 @@ function createStartupFixture(): StartupFixture { "export const inject = ['cmdlineArgs']", 'export function apply(ctx) {', " const program = new Command().name('fixture').option('--generation ', 'echoed generation')", - ' const values = parseCmdline(ctx, program, parsed => ({ generation: parsed.opts().generation }))', - ' if (values !== undefined) ctx.provide(\'fixtureStartup\', values)', + " program.action(() => ctx.provide('fixtureStartup', { generation: program.opts().generation }))", + ' parseCmdline(ctx, program)', '}', '', ].join('\n')) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index f34de5ad07..fcbb00d33f 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -96,7 +96,11 @@ async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promis // document overrides. { id: 'agent-presets', - config: { default: 'standard', roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }] }, + config: { + default: 'standard', + roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }], + includeUserRoot: false, + }, }, ...extra, ] @@ -442,6 +446,7 @@ describe('product subagent rows in user presets', () => { { path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }, { path: userRoot, trust: 'user' }, ], + includeUserRoot: false, }, }]) }, 120_000) @@ -624,6 +629,66 @@ describe('a delegated child', () => { }) }) +describe('a launcher that configures no writable root', () => { + // The claim this default exists for, asserted through the real shipped + // bundles rather than a hand-built context: `apps/cli` patches in only the + // system root, and a person's own presets are found anyway because the + // roster derives `/.agent-presets` itself. `$DSH_HOME` is pointed + // at a temp home BEFORE boot — the derived root is resolved when the plugin + // is constructed, and an unpinned run would read the developer's own. + let derivedCtx: Context + let previousHome: string | undefined + + beforeAll(async () => { + const home = await mkdtemp(join(tmpdir(), 'dsh-preset-derived-')) + previousHome = process.env.DSH_HOME + process.env.DSH_HOME = home + await mkdir(join(home, '.agent-presets', 'derived-mine'), { recursive: true }) + await writeFile( + join(home, '.agent-presets', 'derived-mine', 'agent.cordis.yml'), + '- id: tool-todo\n name: \'@deepseek-ai/dsh-tool-todo\'\n config:\n allowParallelInProgress: true\n', + ) + const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-preset-derived-settings-')), 'settings.yaml') + await writeFile(settingsFile, '{}\n') + // Only the shipped root, exactly what `composeProfile` supplies; the + // writable one is the roster's own default rather than this patch's job. + derivedCtx = await bootWeb(settingsFile, [{ + id: 'agent-presets', + config: { + default: 'standard', + roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }], + includeUserRoot: true, + }, + }]) + }, 120_000) + + afterAll(async () => { + if (previousHome === undefined) delete process.env.DSH_HOME + else process.env.DSH_HOME = previousHome + await derivedCtx.fiber.dispose() + }) + + it('discovers and mounts a preset the person authored under the harness home', async () => { + const listed = await derivedCtx.agentPresets.list() + + const mine = listed.find(preset => preset.id === 'derived-mine') + expect(mine).toMatchObject({ trust: 'user' }) + // Omitted rather than undefined: a healthy row carries no `broken` key. + expect(mine?.broken).toBeUndefined() + expect(derivedCtx.agentPresets.authorable).toBe(true) + + const handle = await derivedCtx.agents.create({ + sessionId: SessionId('preset-derived-root'), + setup: agentCtx => derivedCtx.agentPresets.mount(agentCtx, 'derived-mine').then(() => undefined), + }) + try { + expect(toolNames(derivedCtx, handle.agent)).toContain('todo_write') + } finally { + await handle.dispose() + } + }) +}) + describe('authoring a preset on the shipped composition', () => { let authorCtx: Context let userRoot: string @@ -642,6 +707,7 @@ describe('authoring a preset on the shipped composition', () => { // nothing is the normal first-run state. { path: userRoot, trust: 'user' }, ], + includeUserRoot: false, }, }]) }) diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index be719bda15..35a918c228 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -21,7 +21,12 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn // The sidebar renders from the boot graph: every inject layer activated. const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) - await within(tree).findByText('4 sessions') + // The compact layout dropped group session counts; the fixture workspace + // group row renders immediately with its sessions beneath it. + const fixtureGroup = (await within(tree).findAllByText('fixture')) + .map(el => el.closest('[role="treeitem"]')) + .find(el => el?.getAttribute('aria-expanded') !== null) + if (fixtureGroup === undefined) throw new Error('fixture Workspace group missing') // The resident fixture has both a question and an approval; composer routing // exposes the question first, and the assembled workspace plugin mirrors that diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts index 58d97e5e3e..03198a23a8 100644 --- a/apps/web/tests/chat-long-interactions.e2e.ts +++ b/apps/web/tests/chat-long-interactions.e2e.ts @@ -77,8 +77,13 @@ async function nextPaint(page: Page): Promise { } async function openSeed(page: Page): Promise { - await page.getByText(/^\d+ sessions?$/, { exact: true }).waitFor({ timeout: 30_000 }) - const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true }) + // The compact layout dropped group session counts; the seeded baseline is + // the Ungrouped bucket once cold summaries load. + await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 }) + // Search collapsed into a header action; expand it before filling. + const searchButton = page.getByRole('button', { name: 'Search sessions' }) + if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click() + const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true }) await search.fill(FIXTURE.markers.user(1)) const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') await results.first().waitFor({ timeout: 60_000 }) diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index 0c75afb4e7..5274759f47 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -168,8 +168,10 @@ async function launchScrollWorld(options: ScrollWorldOptions): Promise { } async function openSeed(page: Page, fixture: ChatScrollFixture, tailMarker?: string): Promise { - const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true }) + // Search collapsed into a header action; expand it before filling. + const searchButton = page.getByRole('button', { name: 'Search sessions' }) + if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click() + const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true }) // Cold summaries initially show the temporary workspace basename, so the // persisted first-message marker is the stable user-facing identity. The // query itself triggers lazy content-index reconciliation; no transient diff --git a/apps/web/tests/composer-tab-geometry.e2e.ts b/apps/web/tests/composer-tab-geometry.e2e.ts index 26077f6eab..bb43e20406 100644 --- a/apps/web/tests/composer-tab-geometry.e2e.ts +++ b/apps/web/tests/composer-tab-geometry.e2e.ts @@ -249,7 +249,10 @@ async function compareTabsWithoutReservation(page: Page): Promise * @param page - the page under test. */ async function openSeededSession(page: Page): Promise { - const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true }) + // Search collapsed into a header action; expand it before filling. + const searchButton = page.getByRole('button', { name: 'Search sessions' }) + if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click() + const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true }) await search.fill(FIXTURE.markers.user(1)) const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') const deadline = Date.now() + 60_000 diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 0c7dd36fe3..15b3533070 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -197,8 +197,13 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () it.skipIf(MODE === 'record')('materialized a real Workspace and Session over the wire', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-materialize')) // Browser: the sidebar tree now carries the auto-created workspace group - // with its one session, and the opened session is the selected row. - await expect.poll(() => page.getByText('1 session', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) + // with its one session, and the opened session is the selected row. The + // compact layout dropped group session counts, so the group row itself is + // the barrier. + await expect.poll( + () => page.locator('[role="treeitem"][aria-expanded]').filter({ hasText: 'workspace' }).count(), + { timeout: 15_000 }, + ).toBeGreaterThanOrEqual(1) await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1) await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) // Host: the session's durable header cwd is the folder the workspace diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index c8b6455296..dda4ece74f 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -53,7 +53,10 @@ async function assertBaselineSucceeded(response: Response, method: string): Prom async function ensureSeedOpen(page: Page): Promise { const chat = page.getByRole('tab', { name: 'Chat', exact: true }) - const search = page.getByPlaceholder('Search name, keywords', { exact: false }) + // Search is a collapsed header action; expand it so the input is actionable. + const searchButton = page.getByRole('button', { name: 'Search sessions' }) + if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click() + const search = page.getByPlaceholder('Search sessions', { exact: false }) if (await chat.count() === 0) { await search.fill('WATERFALL') const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') @@ -119,8 +122,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) // The frame mounts before the asynchronous session-list baseline lands. // Search must target the settled seeded row, not the startup input that - // the ready projection replaces. - await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 }) + // the ready projection replaces (the compact layout dropped group session + // counts; the Ungrouped bucket row is the barrier). + await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 }) }, 120_000) afterEach(async () => { @@ -176,9 +180,13 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { it.skipIf(MODE === 'record')('finds an unopened seeded session by message content and opens it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search')) // The API baselines can settle before React commits their projection. The - // seeded count is the final user-visible barrier before editing search. - await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 }) - const search = page.getByPlaceholder('Search name, keywords', { exact: false }) + // seeded Ungrouped bucket row is the final user-visible barrier before + // editing search (the compact layout dropped group session counts). + await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 }) + // Search is a collapsed header action; expand it so the input is actionable. + const searchButton = page.getByRole('button', { name: 'Search sessions' }) + if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click() + const search = page.getByPlaceholder('Search sessions', { exact: false }) // The cold row has not been opened, so only the persisted log can satisfy // this query. First search lazily reconciles the SQLite content index. await search.fill('zzzqx-no-such-session') diff --git a/apps/web/tests/onboarding-usable-provider.e2e.ts b/apps/web/tests/onboarding-usable-provider.e2e.ts new file mode 100644 index 0000000000..09e3923064 --- /dev/null +++ b/apps/web/tests/onboarding-usable-provider.e2e.ts @@ -0,0 +1,128 @@ +// Keyless browser e2e: a user who configures some OTHER provider is not asked +// for the official DeepSeek key again, and the first-run setup card is a card +// they can close. The shipped DeepSeek adapter stays mounted without a +// credential throughout, so the only thing that ends onboarding here is the +// pi-ai route the user configures through the real wire. Zero model calls: +// configuration is pure settings/credentials/llm-domain traffic. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-usable-provider', import.meta.url)) +const DISMISSED_EXPECTED = join(SNAPSHOT_DIR, 'dismissed.expected.md') +const MODE = webSnapshotMode() +const CREDENTIAL_STEP = '添加一个 API Key 开始使用' + +describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first-run onboarding', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ deepSeekMissingCredential: true }) + browser = await chromium.launch() + // The scenario asserts the shipped Chinese copy, so the browser asks for it. + page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('closes the setup card without discarding the add card beside it', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-setup-card-cancel')) + const credentialStep = page.getByRole('region', { name: CREDENTIAL_STEP }) + await credentialStep.waitFor({ timeout: 15_000 }) + await credentialStep.getByRole('button', { name: '前往配置' }).click() + await credentialStep.waitFor({ state: 'detached', timeout: 15_000 }) + + const settings = page.getByRole('dialog', { name: '设置' }) + await settings.waitFor({ timeout: 10_000 }) + // Nothing is reachable yet, so DeepSeek presents itself as its open card. + const setupKey = settings.getByRole('textbox', { name: 'API 密钥', exact: true }) + await setupKey.waitFor({ timeout: 10_000 }) + + const add = settings.getByRole('button', { name: '添加提供方' }) + await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true) + await add.click() + const pick = settings.getByLabel('提供方') + await pick.waitFor({ timeout: 10_000 }) + await pick.selectOption('minimax-cn') + await expect.poll( + async () => settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count(), + { timeout: 10_000 }, + ).toBe(2) + + // Cancelling the setup card is the regression: it used to leave itself open + // and close the add card, discarding that draft. + await settings.getByRole('button', { name: '取消', exact: true }).first().click() + expect(await settings.getByLabel('提供方').count()).toBe(1) + await expect.poll( + async () => settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count(), + { timeout: 10_000 }, + ).toBe(1) + // DeepSeek is now an ordinary row: a missing-key dot and an Edit button. + await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 }) + const dismissed = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(DISMISSED_EXPECTED, dismissed, MODE) + + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('stops prompting for DeepSeek once the other provider can serve requests', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-other-provider')) + const settings = page.getByRole('dialog', { name: '设置' }) + await settings.getByRole('textbox', { name: 'API 密钥', exact: true }).fill('sk-e2e-minimax') + await settings.getByRole('button', { name: '保存', exact: true }).click() + await settings.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 15_000 }) + + // Only minimax-cn is reachable; DeepSeek still holds no credential. + const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') + expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY') + const credentials = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8') + expect(credentials).toContain('MINIMAX_CN_API_KEY: sk-e2e-minimax') + expect(credentials).not.toContain('DEEPSEEK_API_KEY') + + const warningsBefore = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + acknowledgeReloadConnectionLoss(tripwire, warningsBefore) + await page.waitForSelector('[class*="frame"]', { timeout: 15_000 }) + // The regression: the step read only the official route's credential, so a + // fully configured user was taken over on every blank session. + await expect.poll( + async () => page.getByRole('region', { name: CREDENTIAL_STEP }).count(), + { timeout: 10_000 }, + ).toBe(0) + expect(await page.locator('[class*="onboardingStage"]').count()).toBe(0) + expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(false) + + // The Models page agrees: DeepSeek stays a row rather than reopening its + // setup card over a user who already has somewhere to send a request. + await page.getByRole('button', { name: '设置', exact: true }).click() + await settings.waitFor({ timeout: 10_000 }) + await settings.getByRole('button', { name: '模型' }).click() + await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 }) + expect(await settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count()).toBe(0) + + expect((await page.content()).includes('sk-e2e-minimax')).toBe(false) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['dismissed.expected.md']) + }) +}) diff --git a/apps/web/tests/pwsh-terminal.e2e.ts b/apps/web/tests/pwsh-terminal.e2e.ts index 85b52a734f..44912d9ca5 100644 --- a/apps/web/tests/pwsh-terminal.e2e.ts +++ b/apps/web/tests/pwsh-terminal.e2e.ts @@ -68,8 +68,11 @@ describe.skipIf(MODE === 'record' || !HAS_PWSH)('web e2e: pwsh calls use the bas onTestFailed(() => saveFailureShot(page, 'web-e2e-pwsh-terminal')) // Open the seeded session through content search: the sidebar groups // sessions by workspace and its row order is world-dependent, while the - // search index covers the seeded log deterministically. - const search = page.getByPlaceholder('Search name, keywords', { exact: false }) + // search index covers the seeded log deterministically. Search is a + // collapsed header action; expand it so the input is actionable. + const searchButton = page.getByRole('button', { name: 'Search sessions' }) + if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click() + const search = page.getByPlaceholder('Search sessions', { exact: false }) await search.fill('Run a PowerShell command') const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') await expect.poll(() => result.count(), { timeout: 15_000 }).toBe(1) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index d3f373614e..99d1605a5b 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -385,7 +385,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { /** * Reveal the seeded rows: every seeded session is unattached, so they all sit - * in the collapsed Ungrouped bucket. Converges on expanded rather than - * clicking once — startup auto-selection can expand the bucket first, and a - * second click would collapse it again. Hand-rolled polling because + * in the collapsed Ungrouped bucket. Open the bucket, then use its transient + * Show-more control because an open group intentionally renders only five + * rows by default. Hand-rolled polling because * `expect.poll` is test-scoped and this runs in `beforeAll`. * @param page - the page under test. */ @@ -364,6 +364,12 @@ async function expandSeededSessions(page: Page): Promise { if (await bucket.getAttribute('aria-expanded') !== 'true') { await page.getByText('Ungrouped', { exact: true }).click() } + const showMore = page.getByRole('button', { name: /Show \d+ more sessions/ }) + if (await bucket.getAttribute('aria-expanded') === 'true' + && await rows.count() <= SEED_COUNT / 2 + && await showMore.count() > 0) { + await showMore.click() + } if (await bucket.getAttribute('aria-expanded') === 'true' && await rows.count() > SEED_COUNT / 2) return if (Date.now() > deadline) { throw new Error(`Ungrouped bucket never revealed more than ${SEED_COUNT / 2} rows`) diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index c666681b0b..64ff6ae8f0 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -5,17 +5,17 @@ - img - text: New Session - text: Workspaces -- button "Group by": +- button "Search sessions": + - img +- textbox "Search sessions..." +- button "View options": - img - button "Add workspace": - img -- button "Search sessions": - - img -- textbox "Search name, keywords..." - tree "Sessions": - - treeitem "workspace 1 session" [expanded]: + - treeitem "workspace" [expanded]: - img - - text: workspace 1 session + - text: workspace - treeitem "New Session" [selected] - button "Settings": - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md index 2f9c701936..396a9bfa88 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md @@ -5,17 +5,17 @@ - img - text: New Session - text: Workspaces -- button "Group by": +- button "Search sessions": + - img +- textbox "Search sessions..." +- button "View options": - img - button "Add workspace": - img -- button "Search sessions": - - img -- textbox "Search name, keywords..." - tree "Sessions": - - treeitem "workspace 1 session" [expanded]: + - treeitem "workspace" [expanded]: - img - - text: workspace 1 session + - text: workspace - treeitem "New Session" [selected] - button "Settings": - img diff --git a/apps/web/tests/snapshots/message-actions/fork.expected.md b/apps/web/tests/snapshots/message-actions/fork.expected.md index d20754711d..2487b6c374 100644 --- a/apps/web/tests/snapshots/message-actions/fork.expected.md +++ b/apps/web/tests/snapshots/message-actions/fork.expected.md @@ -1,7 +1,7 @@ - tree "Sessions": - - treeitem "Ungrouped 3 sessions" [expanded]: + - treeitem "Ungrouped" [expanded]: - img - - text: Ungrouped 3 sessions - - treeitem "Use the read tool twice (2) now" [selected] - - treeitem "Use the read tool twice (1) now" + - text: Ungrouped - treeitem "Use the read tool twice 1min" + - treeitem "Use the read tool twice (1) now" + - treeitem "Use the read tool twice (2) now" [selected] diff --git a/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md new file mode 100644 index 0000000000..182fadf973 --- /dev/null +++ b/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md @@ -0,0 +1,71 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置": + - img + - text: 通用设置 + - button "模型": + - img + - text: 模型 + - button "Agent 预设": + - img + - text: Agent 预设 + - button "插件配置": + - img + - text: 插件配置 + - button "打开配置文件" + - button "关闭": + - img + - text: 关闭 + - heading "模型" [level=2] + - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - list: + - listitem: + - text: DeepSeek + - img "API 密钥缺失" + - button "编辑 DeepSeek (deepseek-official)": 编辑 + - text: 提供方 + - combobox "提供方": + - option "amazon-bedrock" + - option "ant-ling" + - option "anthropic" + - option "azure-openai-responses" + - option "cerebras" + - option "cloudflare-ai-gateway" + - option "cloudflare-workers-ai" + - option "deepseek" + - option "fireworks" + - option "github-copilot" + - option "google" + - option "google-vertex" + - option "groq" + - option "huggingface" + - option "kimi-coding" + - option "minimax" + - option "minimax-cn" [selected] + - option "mistral" + - option "moonshotai" + - option "moonshotai-cn" + - option "nvidia" + - option "openai" + - option "openai-codex" + - option "opencode" + - option "opencode-go" + - option "openrouter" + - option "qwen-token-plan" + - option "qwen-token-plan-cn" + - option "together" + - option "vercel-ai-gateway" + - option "xai" + - option "xiaomi" + - option "xiaomi-token-plan-ams" + - option "xiaomi-token-plan-cn" + - option "xiaomi-token-plan-sgp" + - option "zai" + - option "zai-coding-cn" + - text: API 密钥 + - textbox "API 密钥": + - /placeholder: 输入 API 密钥,或留空使用环境认证 + - group: 自定义设置 + - button "取消" + - button "保存" diff --git a/apps/web/tests/snapshots/sidebar-subagent-activity/owner-running.expected.md b/apps/web/tests/snapshots/sidebar-subagent-activity/owner-running.expected.md index c5a2766750..a4167b5190 100644 --- a/apps/web/tests/snapshots/sidebar-subagent-activity/owner-running.expected.md +++ b/apps/web/tests/snapshots/sidebar-subagent-activity/owner-running.expected.md @@ -1,6 +1,6 @@ - tree "Sessions": - - treeitem "workspace 2 sessions" [expanded]: + - treeitem "workspace" [expanded]: - img - - text: workspace 2 sessions - - treeitem "1 subagent running Delegate a background task. now" + - text: workspace - treeitem "New Session" [selected] + - treeitem "1 subagent running Delegate a background task. now" diff --git a/apps/web/tests/snapshots/subagent-conversation/fork.expected.md b/apps/web/tests/snapshots/subagent-conversation/fork.expected.md index 020fe01e1a..398c3ceddd 100644 --- a/apps/web/tests/snapshots/subagent-conversation/fork.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/fork.expected.md @@ -1,6 +1,6 @@ - tree "Sessions": - - treeitem "workspace 2 sessions" [expanded]: + - treeitem "workspace" [expanded]: - img - - text: workspace 2 sessions - - treeitem "Explain event sourcing in one (1) now" [selected] + - text: workspace - treeitem "Ask a research subagent to now" + - treeitem "Explain event sourcing in one (1) now" [selected] diff --git a/apps/web/tests/snapshots/subagent-conversation/sidebar.expected.md b/apps/web/tests/snapshots/subagent-conversation/sidebar.expected.md index 934cc4a210..aa3cadb40c 100644 --- a/apps/web/tests/snapshots/subagent-conversation/sidebar.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/sidebar.expected.md @@ -1,5 +1,5 @@ - tree "Sessions": - - treeitem "workspace 1 session" [expanded]: + - treeitem "workspace" [expanded]: - img - - text: workspace 1 session + - text: workspace - treeitem "Ask a research subagent to now" diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 6756b093d2..4b8ecb55e6 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -31,6 +31,8 @@ const ONE_SHOT_LABEL = 'event-sourcing reviewer' const NESTED_LABEL = 'example editor' const PARENT_PROMPT = 'Ask a research subagent to explain event sourcing.' const INITIAL_PROMPT = 'Explain event sourcing in one sentence.' +/** The grandchild's own first message; its arrival is what says its history finished loading. */ +const NESTED_PROMPT = 'Give one concrete event sourcing example.' const FOLLOWUP = 'Now give the same explanation to a human reader.' const POST_FORK_FOLLOWUP = 'Continue the original conversation after the fork.' @@ -176,7 +178,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = seq: 1, time: authoredAt + 1, data: { - content: [{ type: 'text', text: 'Give one concrete event sourcing example.' }], + content: [{ type: 'text', text: NESTED_PROMPT }], source: { kind: 'user' }, }, surfaceOp: 'append', @@ -404,6 +406,11 @@ describe('web e2e: persisted subagent conversation and human continuation', () = ) await nestedRow.click() await page.getByText('The parent session is offline; reopen it to continue sending messages.').waitFor() + // The offline banner renders from the descriptor alone, so it says nothing + // about the transcript below it. The golden pins that transcript, and + // `captureStableAria` calls two identical polls stable — including two of + // "Loading history…". Wait for the message the golden asserts. + await page.getByText(NESTED_PROMPT).waitFor() const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) const crumbs = await hierarchy.getByRole('button').allTextContents() expect(crumbs.slice(-2)).toEqual([LABEL, NESTED_LABEL]) diff --git a/apps/web/tests/trajectory-virtualization.e2e.ts b/apps/web/tests/trajectory-virtualization.e2e.ts index 287d38c29d..64f149646b 100644 --- a/apps/web/tests/trajectory-virtualization.e2e.ts +++ b/apps/web/tests/trajectory-virtualization.e2e.ts @@ -59,7 +59,10 @@ interface RowAnchor { } async function openSeed(page: Page): Promise { - const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true }) + // Search collapsed into a header action; expand it before filling. + const searchButton = page.getByRole('button', { name: 'Search sessions' }) + if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click() + const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true }) await search.fill(FIXTURE.markers.user(1)) const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') await expect.poll(() => result.count(), { timeout: 60_000 }).toBe(1) @@ -182,7 +185,9 @@ describe('web e2e: Trajectory virtualization over tail-paged history', () => { tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 }) + // The compact layout dropped group session counts; the seeded baseline is + // the Ungrouped bucket once cold summaries load. + await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 }) }, 120_000) afterAll(async () => { diff --git a/apps/web/tests/workflow-run.e2e.ts b/apps/web/tests/workflow-run.e2e.ts index eafb78223f..4cbae8e6e2 100644 --- a/apps/web/tests/workflow-run.e2e.ts +++ b/apps/web/tests/workflow-run.e2e.ts @@ -74,12 +74,16 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = await input.fill(prompt) await input.press('Enter') - const workflow = page.getByRole('button', { name: /^snapshot-flow/ }) + const workflow = page.locator('[data-workflow-run][data-run-status="running"]') await workflow.waitFor({ timeout: 30_000 }) - expect(await workflow.getAttribute('aria-expanded')).toBe('true') - const phase = page.getByRole('button', { name: /^Run/ }) - await phase.waitFor({ timeout: 15_000 }) - await phase.click() + const disclosures = workflow.locator('[data-disclosure-row]') + await disclosures.nth(1).waitFor({ timeout: 15_000 }) + expect(await disclosures.nth(0).getAttribute('role')).toBeNull() + expect(await disclosures.nth(0).getAttribute('aria-expanded')).toBeNull() + expect(await disclosures.nth(1).getAttribute('role')).toBeNull() + expect(await disclosures.nth(1).getAttribute('aria-expanded')).toBeNull() + expect(await disclosures.nth(0).evaluate(element => getComputedStyle(element).cursor)).not.toBe('pointer') + expect(await disclosures.nth(1).evaluate(element => getComputedStyle(element).cursor)).not.toBe('pointer') const member = page.getByRole('button', { name: /^Open Reply with exactly the word/ }) await member.waitFor({ timeout: 15_000 }) await member.focus() @@ -139,15 +143,20 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = const sessions = page.getByRole('tree', { name: 'Sessions' }) await sessions.getByRole('treeitem', { name: /Use the workflow tool exactly/ }).click() await settled + await page.locator('[data-workflow-run][data-run-status="completed"]').waitFor() expect(await page.locator('[data-chat-flow-kind="tool-call"]').count()).toBeGreaterThanOrEqual(1) expect(await page.locator('[data-chat-flow-kind="workflow-run"]').count()).toBe(1) const terminalWorkflow = page.getByRole('button', { name: /^snapshot-flow/ }) await terminalWorkflow.waitFor() - if (await terminalWorkflow.getAttribute('aria-expanded') !== 'true') await terminalWorkflow.click() + expect(await terminalWorkflow.getAttribute('aria-expanded')).toBe('false') + expect(await terminalWorkflow.evaluate(element => getComputedStyle(element).cursor)).toBe('pointer') + await terminalWorkflow.click() const terminalPhase = page.getByRole('button', { name: /^Run/ }) await terminalPhase.waitFor() - if (await terminalPhase.getAttribute('aria-expanded') !== 'true') await terminalPhase.click() + expect(await terminalPhase.getAttribute('aria-expanded')).toBe('false') + expect(await terminalPhase.evaluate(element => getComputedStyle(element).cursor)).toBe('pointer') + await terminalPhase.click() await page.getByText(CHILD_PROMPT, { exact: false }).waitFor() await expect.poll( () => page.getByRole('button', { name: /^Open Reply with exactly the word/ }).count(), @@ -165,6 +174,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = await workflow.click() const phase = page.getByRole('button', { name: /^Run/ }) await phase.waitFor() + expect(await phase.getAttribute('aria-expanded')).toBe('false') await phase.click() await page.getByText(CHILD_PROMPT, { exact: false }).waitFor() expect(await page.getByRole('button', { name: /^Open Reply with exactly the word/ }).count()).toBe(0) diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index a435592da5..e5a25e1c6c 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -372,21 +372,22 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff // Grouped default: workspace group rows render (the seeded session sits // under Ungrouped; the created workspaces are empty groups). await expect.poll(() => page.getByText('Workspaces', { exact: true }).count(), { timeout: 10_000 }).toBe(1) - await page.getByRole('button', { name: 'Group by' }).click() + // Grouping and ordering moved into the View options menu. + await page.getByRole('button', { name: 'View options' }).click() await page.getByRole('menuitem', { name: 'In one list' }).click() // Flat mode: the section label flips and the seeded session is a // top-level row with no group headers above it. await expect.poll(() => page.getByText('Sessions', { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 5_000 }).toBe(0) await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) - expect(await page.evaluate(() => localStorage.getItem('dsh.workspace.view'))).toContain('flat') + expect(await page.evaluate(() => localStorage.getItem('dsh.workspace.view.v4'))).toContain('flat') // Persisted across reload; then restore grouped for inter-spec hygiene. const warningStart = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) acknowledgeReloadConnectionLoss(tripwire, warningStart) await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 15_000 }).toBe(0) - await page.getByRole('button', { name: 'Group by' }).click() + await page.getByRole('button', { name: 'View options' }).click() await page.getByRole('menuitem', { name: 'WorkSpace' }).click() await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) expect(tripwire.pageErrors).toEqual([]) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index b1153f4b8e..e656b099ea 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -42,6 +42,7 @@ "tests/default-model.e2e.ts", "tests/declared-reasoning.e2e.ts", "tests/onboarding-deepseek-config.e2e.ts", + "tests/onboarding-usable-provider.e2e.ts", "tests/remote-welcome.e2e.ts", "tests/workspace-management.e2e.ts", "tests/replay-round-trip.e2e.ts", diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 0b890485cd..7b380a0698 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: e7ab120218c6de909bbd799c19b38eef524c7555 -config-catalog.zh.md: 622a0372de2f194e7063312ad7b8343a0bc58f06 +config-catalog.md: 2bb1f315e02c3f4bab379593227f1c381818a9fa +config-catalog.zh.md: 4be51942de65a7e8afdaf71a44573ba1cdd6230d diff --git a/docs/config-catalog.md b/docs/config-catalog.md index e7ab120218..2bb1f315e0 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -135,6 +135,11 @@ export interface Config { default: string /** Scanned roots in precedence order; an earlier root wins a duplicate id. */ roots: PresetRoot[] + /** + * Append the harness home's `USER_PRESET_DIR` as a `user` root, after every + * configured root. False mounts a roster over `roots` alone. + */ + includeUserRoot: boolean } /** One directory scanned for preset subdirectories. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 622a0372de..4be51942de 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -137,6 +137,11 @@ export interface Config { default: string /** Scanned roots in precedence order; an earlier root wins a duplicate id. */ roots: PresetRoot[] + /** + * Append the harness home's `USER_PRESET_DIR` as a `user` root, after every + * configured root. False mounts a roster over `roots` alone. + */ + includeUserRoot: boolean } /** One directory scanned for preset subdirectories. */ diff --git a/docs/cordis-tutorial/index.i18n.yaml b/docs/cordis-tutorial/index.i18n.yaml index 06857ab177..1ce61a593a 100644 --- a/docs/cordis-tutorial/index.i18n.yaml +++ b/docs/cordis-tutorial/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/index.md -index.md: fb700344e6d07d3864655009d2edac15ee9eede8 -index.zh.md: a68e931d81e745164d8f9a5dc7ec9aec4cd0e590 +index.md: a10a0f93fde4f710af2ab14f74b854ee07d7c03f +index.zh.md: fb2c4f0959eab8c7a072c44207943c31b0bed8ea diff --git a/docs/cordis-tutorial/index.md b/docs/cordis-tutorial/index.md index fb700344e6..a10a0f93fd 100644 --- a/docs/cordis-tutorial/index.md +++ b/docs/cordis-tutorial/index.md @@ -10,7 +10,7 @@ If you want the condensed concept reference instead of a walkthrough, read the [ ## Setup -You need a clone of this repository with dependencies installed — the [quick start](../user/guide/quickstart.md) covers prerequisites. No API key is needed for this tutorial; every example runs keylessly. +You need a clone of this repository with dependencies installed; the [development guide](../development.md#setup-tutorial) lists the prerequisites. No API key is needed for this tutorial; every example runs keylessly. ```sh git clone https://github.com/deepseek-ai/deepseek-harness.git diff --git a/docs/cordis-tutorial/index.zh.md b/docs/cordis-tutorial/index.zh.md index a68e931d81..fb2c4f0959 100644 --- a/docs/cordis-tutorial/index.zh.md +++ b/docs/cordis-tutorial/index.zh.md @@ -10,7 +10,7 @@ Cordis 是 DeepSeek Harness SDK 底层的插件框架:它是一个小型运行 ## 准备工作 -你需要克隆本仓库并安装依赖,具体前置条件见[快速入门](../user/guide/quickstart.md)。本教程不需要 API 密钥;所有示例均可在无密钥环境中运行。 +你需要克隆本仓库并安装依赖;[开发指南](../development.md#setup-tutorial)列出了前置条件。本教程不需要 API 密钥;所有示例均可在无密钥环境中运行。 ```sh git clone https://github.com/deepseek-ai/deepseek-harness.git diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index ff6ed9b466..5f5d6aec22 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: e52a7619085b2956496be6234f711441902fc259 -core.zh.md: e9cfe19129e33a1c17e87561397b13139a6d7537 +core.md: 2e89bac4c0468c094814aa7137381f4be569fc29 +core.zh.md: 2a8a35bb46adf28fd2ba07d0a319cdca0cbb4a2f diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index e52a761908..2e89bac4c0 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -546,7 +546,7 @@ async standingKeyFor(id?: string): Promise Types: [ScopeKey](scope.md) -Source: [`packages/preset/agent-presets/src/index.ts:81`](../../packages/preset/agent-presets/src/index.ts) +Source: [`packages/preset/agent-presets/src/index.ts:82`](../../packages/preset/agent-presets/src/index.ts) diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index e9cfe19129..2a8a35bb46 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -554,7 +554,7 @@ async standingKeyFor(id?: string): Promise Types: [ScopeKey](scope.md) -Source: [`packages/preset/agent-presets/src/index.ts:81`](../../packages/preset/agent-presets/src/index.ts) +Source: [`packages/preset/agent-presets/src/index.ts:82`](../../packages/preset/agent-presets/src/index.ts) diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index 58a9ad9c33..13199e16a6 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/workspace.md -workspace.md: 7bd5fda31e5d5c29d7446589b9af2679a46cba9a -workspace.zh.md: 288b9468cc9e75613ab89efbe516eb287dae0441 +workspace.md: 480279cf4ed2c7a0005ea6a8ca8f58c208219e86 +workspace.zh.md: 6d9a9ad5dab1d11ea61fb0274ea3a91eb8929c5a diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index 7bd5fda31e..480279cf4e 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -194,6 +194,15 @@ list(): Workspace[] */ delete(id: WorkspaceId): Promise +/** + * Move one workspace within the durable display order, DOM-insertBefore-like. + * With an anchor it lands before that workspace; without one it appends. + * @param id - Workspace to move. + * @param beforeId - Workspace anchor; omitted appends. + * @returns the complete committed workspace order. + */ +insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise + /** * Archive one session durably. The session must exist (live or in session * persistence); its workspace accounting — or lack of one — is irrelevant. @@ -215,5 +224,5 @@ async resolveByPath(path: string): Promise Types: [SessionId](core.md) -Source: [`packages/workspace/workspace/src/index.ts:81`](../../packages/workspace/workspace/src/index.ts) +Source: [`packages/workspace/workspace/src/index.ts:92`](../../packages/workspace/workspace/src/index.ts) diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index 288b9468cc..6d9a9ad5da 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -194,6 +194,15 @@ list(): Workspace[] */ delete(id: WorkspaceId): Promise +/** + * Move one workspace within the durable display order, DOM-insertBefore-like. + * With an anchor it lands before that workspace; without one it appends. + * @param id - Workspace to move. + * @param beforeId - Workspace anchor; omitted appends. + * @returns the complete committed workspace order. + */ +insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise + /** * Archive one session durably. The session must exist (live or in session * persistence); its workspace accounting — or lack of one — is irrelevant. @@ -215,5 +224,5 @@ async resolveByPath(path: string): Promise Types: [SessionId](core.md) -Source: [`packages/workspace/workspace/src/index.ts:81`](../../packages/workspace/workspace/src/index.ts) +Source: [`packages/workspace/workspace/src/index.ts:92`](../../packages/workspace/workspace/src/index.ts) diff --git a/docs/user/develop/basic/index.i18n.yaml b/docs/user/develop/basic/index.i18n.yaml index 6684d18070..1bfaea7b92 100644 --- a/docs/user/develop/basic/index.i18n.yaml +++ b/docs/user/develop/basic/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/basic/index.md -index.md: e57a42b42690bd92450cc26876c13a1622bb80cc -index.zh.md: 240623341618acd6501f2897ae2844fde0a5b73b +index.md: 71b5bd5ef5d296999420c40d3b8c9cf46c918841 +index.zh.md: 5dafe8bf0938337fa1f38634088acf00a2fcab46 diff --git a/docs/user/develop/basic/index.md b/docs/user/develop/basic/index.md index e57a42b426..71b5bd5ef5 100644 --- a/docs/user/develop/basic/index.md +++ b/docs/user/develop/basic/index.md @@ -2,7 +2,7 @@ English | [中文](index.zh.md) -This tutorial creates a minimal Harness plugin and loads it into the Web UI. Start from a repository checkout that has completed the [quick start](../../guide/quickstart.md). +This tutorial creates a minimal Harness plugin and loads it into the Web UI. Start from a repository checkout that has completed the [run-from-source path](../../../../README.md#run-from-source). ## Create a local project diff --git a/docs/user/develop/basic/index.zh.md b/docs/user/develop/basic/index.zh.md index 2406233416..5dafe8bf09 100644 --- a/docs/user/develop/basic/index.zh.md +++ b/docs/user/develop/basic/index.zh.md @@ -2,7 +2,7 @@ [English](index.md) | 中文 -本教程会创建一个最小的 Harness 插件,并将其加载到 Web UI 中。请从已完成[快速开始](../../guide/quickstart.md)的仓库检出开始。 +本教程会创建一个最小的 Harness 插件,并将其加载到 Web UI 中。请从已完成[从源码运行路径](../../../../README.md#run-from-source)的仓库检出开始。 ## 创建本地项目 diff --git a/docs/user/develop/basic/publish.i18n.yaml b/docs/user/develop/basic/publish.i18n.yaml index 91dba947bb..a7b9b1d39a 100644 --- a/docs/user/develop/basic/publish.i18n.yaml +++ b/docs/user/develop/basic/publish.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/basic/publish.md -publish.md: 8437c7ea5c4cb966f9f3d68977949c78986ec9a5 -publish.zh.md: 4409dbfda060a84b316029d87ec985209cfa286a +publish.md: 588531a28020ebe620643cd1aaaa43de000e658a +publish.zh.md: 938e4b0aa2ea09f80fce897413e9c57f90d3209a diff --git a/docs/user/develop/basic/publish.md b/docs/user/develop/basic/publish.md index 8437c7ea5c..588531a280 100644 --- a/docs/user/develop/basic/publish.md +++ b/docs/user/develop/basic/publish.md @@ -117,7 +117,7 @@ A bundle that defines a runnable app mounts an ordinary provider plugin: name: 'dsh-hello-plugin/startup' ``` -The plugin exports `inject = ['cmdlineArgs']`, calls `parseCmdline` from [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) with its own commander program, and provides the returned value as its app-owned service. The launcher hands every plugin the same immutable arguments after launcher flags, so app-specific flags need no launcher change and multiple plugins may parse the snapshot. The Loader row needs no launcher marker or special kind. +The plugin exports `inject = ['cmdlineArgs']`, calls `parseCmdline` from [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) with its own commander program, and provides its app-owned service from the program's action. The launcher hands every plugin the same immutable arguments after launcher flags, so app-specific flags need no launcher change and multiple plugins may parse the snapshot. The Loader row needs no launcher marker or special kind. Rows configured by those arguments inject the provider's service and read it from their own `!!js` options, with the deployment value beside it as the fallback: diff --git a/docs/user/develop/basic/publish.zh.md b/docs/user/develop/basic/publish.zh.md index 4409dbfda0..938e4b0aa2 100644 --- a/docs/user/develop/basic/publish.zh.md +++ b/docs/user/develop/basic/publish.zh.md @@ -117,7 +117,7 @@ dsh --profile demo name: 'dsh-hello-plugin/startup' ``` -该插件导出 `inject = ['cmdlineArgs']`,使用自己的 commander program 调用 [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) 中的 `parseCmdline`,再把返回值作为应用自有服务提供出去。启动器把自身 flag 之后的同一份不可变参数交给每个插件,因此添加应用专属 flag 无需修改启动器,多个插件也可以解析该快照。Loader 行不需要启动器标记或特殊类型。 +该插件导出 `inject = ['cmdlineArgs']`,使用自己的 commander program 调用 [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) 中的 `parseCmdline`,再在 program 自己的 action 中把应用自有服务提供出去。启动器把自身 flag 之后的同一份不可变参数交给每个插件,因此添加应用专属 flag 无需修改启动器,多个插件也可以解析该快照。Loader 行不需要启动器标记或特殊类型。 受这些参数配置的行会注入提供方服务,并在自己的 `!!js` 选项中读取它,同时把部署取值写在旁边作为回退: diff --git a/docs/user/guide/config.i18n.yaml b/docs/user/guide/config.i18n.yaml deleted file mode 100644 index 00a8867092..0000000000 --- a/docs/user/guide/config.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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 docs/user/guide/config.md -config.md: 1d3ad5ce36d4b360ba5156b6be28a6caae4a23d4 -config.zh.md: 7f8bfaa77066f2976a5667e3ac402814a7afdf96 diff --git a/docs/user/guide/config.md b/docs/user/guide/config.md deleted file mode 100644 index 1d3ad5ce36..0000000000 --- a/docs/user/guide/config.md +++ /dev/null @@ -1,72 +0,0 @@ -# Configuration - -English | [中文](config.zh.md) - -Harness uses `cordis.yml` to describe which plugins an agent loads and the configuration passed to each one. The file composes capabilities; the generated configuration catalog records the fields and defaults each package actually supports. - -## Start from a real configuration - -The repository examples are runnable configurations and the most reliable starting points for a new project: - -- [the `dsh-base` bundle patch](../../../packages/bundle/base/cordis.patch.yml) provides the common model, tools, persistence, policy, and telemetry rows every profile starts from. -- [the `dsh-web-app` bundle patch](../../../packages/bundle/web-app/cordis.patch.yml) adds the browser host, Workspace management, browser interaction, and client plugins. -- [headless-agent](../../../examples/headless-agent/cordis.yml) exposes the coding composition as a one-shot task. -- [acp-agent](../../../examples/acp-agent/cordis.yml) exposes fresh sessions to programmatic ACP clients. - -A minimal configuration is a list of plugin entries: - -```yaml -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - models: - - deepseek-v4-flash - -- id: bash - name: '@deepseek-ai/dsh-bash-local' - -- id: agent-loop - name: '@deepseek-ai/dsh-agent-loop' - config: - agents: - - id: main - provider: deepseek-official - model: deepseek-v4-flash -``` - -## Plugin entries - -`name` identifies an npm package or a local module relative to `cordis.yml`; `id` gives the plugin instance a stable identity; and `config` supplies plugin-specific configuration. Set `disabled: true` to skip an entry temporarily. - -```yaml -- id: local-tool - name: './src/my-tool.ts' - disabled: false - config: - toolName: my_tool -``` - -Cordis starts sibling entries concurrently. A plugin declares required services through `inject`; Cordis waits for those services before applying the plugin, so file order does not establish dependency readiness. Missing models, tools, and plugins fail as early as possible instead of being silently ignored. - -## CLI patch layers - -`dsh --profile ` composes the profile's bundle patch layers (its manifest's `dsh.profile.bundles` list, in order) over an empty root, then the profile's own `~/.dsh/profiles//cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and each `--patch ` overlay. Later layers win per row. App flags are not another patch layer: an ordinary bundle plugin injects `cmdlineArgs` and provides parsed values as its own service, while rows that inject and retain a `!!js` read of that service give the invocation value precedence. - -A patch replaces a row's entire `config` value; it does not deep-merge keys. For example, patching `llm-deepseek` with only `config: { thinking: disabled }` also removes that row's configured `apiKey` and `baseURL`, so restate every key the row must retain. - -## JavaScript values and environment variables - -The Cordis loader evaluates runtime expressions tagged with `!!js`. Keep API keys and other secrets in the gitignored `.env` file at the repository root, never in committed configuration. - -```yaml -config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - cwd: !!js process.cwd() -``` - -The tag is `!!js`, not `!js`. - -## Exact configuration reference - -The generated [plugin configuration catalog](../../config-catalog.md) lists every current field, type, and default. For composition concepts, continue to the [architecture](../../architecture.md) and [capability seams](../../capability-seams.md). To create a configuration, copy the closest entry from the [examples overview](../../../examples/README.md) and adapt it. diff --git a/docs/user/guide/config.zh.md b/docs/user/guide/config.zh.md deleted file mode 100644 index 7f8bfaa770..0000000000 --- a/docs/user/guide/config.zh.md +++ /dev/null @@ -1,72 +0,0 @@ -# 配置文件 - -[English](config.md) | 中文 - -Harness 使用 `cordis.yml` 描述 agent(智能体)加载哪些插件以及每个插件的参数。配置文件负责组合能力;每个包真正支持的字段和默认值由源码生成的配置目录负责记录。 - -## 从真实配置开始 - -仓库中的示例就是可以运行的配置,也是新项目最可靠的起点: - -- [`dsh-base` 组合包补丁](../../../packages/bundle/base/cordis.patch.yml) 提供通用的模型、工具、持久化、策略与遥测配置项,每个 profile 都以此为起点。 -- [`dsh-web-app` 组合包补丁](../../../packages/bundle/web-app/cordis.patch.yml) 添加浏览器宿主、Workspace 管理、浏览器交互与客户端插件。 -- [headless-agent](../../../examples/headless-agent/cordis.yml) 以单次任务形式暴露 coding 组装。 -- [acp-agent](../../../examples/acp-agent/cordis.yml) 向程序化 ACP(Agent Client Protocol)客户端提供全新会话。 - -最小配置由一组插件条目组成: - -```yaml -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - models: - - deepseek-v4-flash - -- id: bash - name: '@deepseek-ai/dsh-bash-local' - -- id: agent-loop - name: '@deepseek-ai/dsh-agent-loop' - config: - agents: - - id: main - provider: deepseek-official - model: deepseek-v4-flash -``` - -## 插件条目 - -`name` 指定 npm 包或相对于 `cordis.yml` 的本地模块,`id` 为插件实例提供稳定标识,`config` 传入插件自己的配置。需要临时跳过某个条目时可设置 `disabled: true`。 - -```yaml -- id: local-tool - name: './src/my-tool.ts' - disabled: false - config: - toolName: my_tool -``` - -Cordis 会并发启动同级配置项。插件通过 `inject` 声明必需服务;Cordis 会等到这些服务就绪后再应用该插件,因此文件顺序不能保证依赖已就绪。引用不存在的模型、工具或插件会尽早报错,而不是被静默忽略。 - -## CLI 补丁层 - -`dsh --profile ` 按该 profile 的 manifest(元数据清单)中 `dsh.profile.bundles` 列表的顺序,在空根之上组合各组合包补丁层,随后依次应用该 profile 自己的 `~/.dsh/profiles//cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 与每个 `--patch ` overlay。同一行以较后的层为准。应用 flag 并不是另一层 patch:组合包中的普通插件注入 `cmdlineArgs`,再把解析值作为自身服务提供;注入该服务并保留其 `!!js` 读取的行会让本次调用的取值优先。 - -补丁会替换目标行的整个 `config` 值,而不是深度合并各个键。例如,只用 `config: { thinking: disabled }` 修补 `llm-deepseek`,也会移除该行原有的 `apiKey` 与 `baseURL`;因此必须重新写出该行需要保留的全部键。 - -## JavaScript 值和环境变量 - -Cordis loader 使用 `!!js` 标签读取运行时表达式。API key 等凭据应放在仓库根目录、已被 Git 忽略的 `.env` 中,不能提交到配置文件。 - -```yaml -config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - cwd: !!js process.cwd() -``` - -标签是 `!!js`,不是 `!js`。 - -## 精确配置参考 - -每个插件当前支持的字段、类型和默认值见自动生成的[插件配置目录](../../config-catalog.md)。理解插件如何组合可继续阅读[架构说明](../../architecture.md)和[能力接口](../../capability-seams.md);要创建自己的配置,优先复制并修改[示例目录说明](../../../examples/README.md)中最接近的例子。 diff --git a/docs/user/guide/index.i18n.yaml b/docs/user/guide/index.i18n.yaml index 056122d1fb..51f8a8802d 100644 --- a/docs/user/guide/index.i18n.yaml +++ b/docs/user/guide/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/index.md -index.md: a04698e29755d4a08b012f8b61accb79c470dcb0 -index.zh.md: 3808d9506fa9cb3a3e455ed478e4f02c18fc09ab +index.md: 80d288b1aba37e7f0863fe5fc8237cbd2a6ab9b5 +index.zh.md: addfbc94ff93ed015e52f509a23a3f981e36770b diff --git a/docs/user/guide/index.md b/docs/user/guide/index.md index a04698e297..80d288b1ab 100644 --- a/docs/user/guide/index.md +++ b/docs/user/guide/index.md @@ -1,52 +1,28 @@ -# Introduction +# Use the Web UI English | [中文](index.zh.md) -DeepSeek Harness is a **plugin-based agent development framework** built on the [Cordis](https://github.com/cordiverse/cordis) microkernel. Its central idea is simple: **everything is a plugin**. +Start the Web UI through the [root README](../../../README.md#run); the command prints its URL. This guide begins after that server is running. -## What it is +The invoking directory is the default workspace, so the agent can inspect and modify the project where you started `dsh`. -Harness implements every capability an AI agent needs—including LLM calls, tool execution, session management, and subtask delegation—as a composable plugin. A `cordis.yml` file declares which plugins to load and how to configure them, assembling a complete agent. +## Configure a model -```yaml -# Select the LLM backend -- name: '@deepseek-ai/dsh-llm-deepseek' +Open **Settings → Models**, enter a DeepSeek API key, and save it. The model route becomes usable immediately without restarting the server. -# Compose one configured agent -- id: agent-spine - name: '@deepseek-ai/dsh-agent-spine-demo' - config: - agents: - - id: main - provider: deepseek-official - model: deepseek-v4-flash - workspaceContext: false -``` +The [model configuration guide](./providers.md) covers other providers and custom OpenAI-compatible endpoints. -## Who it is for +## Run a task -### Application users +Start a session and send: -To run an existing agent application, such as a coding assistant or conversational agent: +> Summarize this repository and identify its main packages. -1. Copy an example template. -2. Add an API key. -3. Run it. +The agent can read and edit workspace files, run commands, delegate work, and maintain a plan. The Web UI asks before operations that require approval under the active permission policy. -No code is required. See the [quick start](./quickstart.md). +## Continue -### Plugin developers - -To add a custom tool, a new LLM adapter, or another execution backend, write a plugin. Harness provides explicit extension interfaces and a type-safe development experience. See [development](../develop/basic/). - -## Core features - -- **Configuration only** — `cordis.yml` selects the capability set; changing a model or adding a tool is a configuration edit. -- **Hot replacement (HMR)** — edit plugin code during development without restarting the process. - -## Technology - -- **Runtime**: Node.js ^22.19 or >= 24 -- **Language**: TypeScript (ESM) -- **Framework**: Cordis -- **Package manager**: pnpm workspaces (the repository pins pnpm 11) +- [Configure models](./providers.md) +- [Use the Python SDK](./python-sdk.md) +- [Use other CLI modes](../../../apps/cli/README.md) +- [Develop a plugin](../develop/basic/) diff --git a/docs/user/guide/index.zh.md b/docs/user/guide/index.zh.md index 3808d9506f..addfbc94ff 100644 --- a/docs/user/guide/index.zh.md +++ b/docs/user/guide/index.zh.md @@ -1,52 +1,28 @@ -# 介绍 +# 使用 Web UI [English](index.md) | 中文 -DeepSeek Harness 是一个**插件化的 agent(智能体)开发框架**,基于 [Cordis](https://github.com/cordiverse/cordis) 微内核构建。它的核心理念是:**一切皆插件**。 +先按照[根 README](../../../README.md#run)启动 Web UI;命令会打印其访问地址。本指南从服务器已经运行的状态开始。 -## 它是什么 +调用目录是默认工作区,因此 agent(智能体)可以检查并修改启动 `dsh` 时所在的项目。 -Harness 将 AI(人工智能) agent 所需的所有能力——LLM(大语言模型)调用、工具执行、会话管理、子任务分配——全部构建为可组合的插件。你通过一个 `cordis.yml` 配置文件来声明加载哪些插件、使用什么参数,就能组装出一个完整的 agent。 +## 配置模型 -```yaml -# Select the LLM backend -- name: '@deepseek-ai/dsh-llm-deepseek' +打开**设置 → 模型**,输入 DeepSeek API 密钥并保存。模型路由会立即可用,不需要重启服务器。 -# Compose one configured agent -- id: agent-spine - name: '@deepseek-ai/dsh-agent-spine-demo' - config: - agents: - - id: main - provider: deepseek-official - model: deepseek-v4-flash - workspaceContext: false -``` +[模型配置指南](./providers.md)介绍其他提供方和自定义 OpenAI 兼容端点。 -## 适合谁 +## 运行任务 -### 应用使用者 +启动一个会话并发送: -如果你只是想用一个现成的 agent 应用(如编程助手、对话代理),你需要的全部操作就是: +> Summarize this repository and identify its main packages. -1. 复制一个示例模板。 -2. 填写 API 密钥。 -3. 运行。 +agent 可以读取和编辑工作区文件、运行命令、委派工作并维护计划。当操作在当前权限策略下需要审批时,Web UI 会先询问你。 -不需要写任何代码。详见 [快速开始](./quickstart.md)。 +## 继续使用 -### 插件开发者 - -如果你想为 agent 添加新能力——一个自定义工具、一个新的 LLM 适配器、一个新的执行后端——你需要编写一个插件。Harness 提供了清晰的扩展接口和类型安全的开发体验。详见 [开发](../develop/basic/)。 - -## 核心功能 - -- **只需要配置** — `cordis.yml` 决定能力集合,换模型、加工具只需改一行 -- **HMR(热模块替换)** — 开发时修改插件代码,无需重启进程 - -## 技术栈 - -- **运行时**:Node.js ^22.19 或 >= 24 -- **语言**:TypeScript(ESM) -- **框架**:Cordis -- **包管理**:pnpm workspaces(仓库固定使用 pnpm 11) +- [配置模型](./providers.md) +- [使用 Python SDK](./python-sdk.md) +- [使用其他 CLI 模式](../../../apps/cli/README.md) +- [开发插件](../develop/basic/) diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 9da7fd1113..7dc9c5c42a 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/providers.md -providers.md: 0e7ed11d1b09a8361d75b576a400978ac66d08a7 -providers.zh.md: 060bf3dc41b773e89cd0d78de921c3a20cfc6076 +providers.md: a3f94f0cc86401c0f9e5b94cfd823bf9f08e6bfc +providers.zh.md: 7d74e0086e62d8a0c2fb39085207125b4b4354e7 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 0e7ed11d1b..a3f94f0cc8 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -2,151 +2,44 @@ English | [中文](providers.zh.md) -Harness ships with DeepSeek and mounts a generic multi-provider adapter alongside it, for the providers in pi-ai's installed catalog — Anthropic, OpenAI, and the rest — and for any OpenAI-compatible gateway or self-hosted server. You have two entry points: the **Models** page in the web UI, and `$DSH_HOME/settings.yaml`. Both write the same document, and a change takes effect on the next request without a restart. +This guide assumes you started the Web UI through the [root README](../../../README.md#run). Model changes take effect on the next request without restarting the server. -## Where providers come from +## Configure DeepSeek -`cordis.yml` decides which **adapters** are installed; the settings document decides which **providers** run. The shipped composition carries two LLM adapters: - -- `llm-deepseek` serves the `deepseek-official` route, the one available out of the box. -- `llm-pi-ai` mounts **dormant**: zero routes and no extra entries in the model picker until an `llm-pi-ai:` settings section supplies provider profiles, at which point those routes register live and drop again when the section empties. - -Adding a provider therefore rarely means editing `cordis.yml` — writing settings is enough, and that is exactly what the Models page does. - -## Configure from the web UI - -Start `pnpm dsh web` and open **Settings → Models**. +Open **Settings → Models**. The DeepSeek card exposes one API-key field; enter the key and save it. ![The Models page: the DeepSeek card, with Add provider and Add a custom provider below it](providers-models-page.png) -**Give DeepSeek its key.** The DeepSeek card carries one API-key field; fill it in, save, and the provider is ready. +Keys are write-only. The page receives a redacted descriptor after saving, never the literal secret. The key is stored in `$DSH_HOME/.credentials.yaml`, while settings retain only its credential reference. -**Add a provider from the installed catalog.** Choose **Add provider**, pick one of pi-ai's catalog providers (anthropic, openai, and so on), and enter that provider's API key. The endpoint, protocol, and model catalog all come from the catalog; the key is the only thing you owe. +## Add a catalog provider -That holds for providers that authenticate with an API key. The catalog also carries Bedrock, Vertex, Azure, and Codex, which need AWS credentials and a region, an ADC project, an `api-version`, and OAuth respectively: filling in the key field alone will not make them work. Those authenticate through pi-ai's own environment discovery, with credentials prepared the way each one requires. +Choose **Add provider**, select a provider such as Anthropic or OpenAI, enter its API key, and save. The installed catalog supplies the endpoint, protocol, and model list. -**Add a custom provider.** Choose **Add a custom provider** for a route the catalog does not ship — a company gateway, a self-hosted server, or a provider newer than the installed catalog. It asks for a Provider ID (the lowercase identifier that names the route in requests and as its credential), a base URL, a protocol, and at least one model. +Providers with native authentication need their native credentials instead. Bedrock, Vertex, Azure, and Codex use AWS credentials and a region, an ADC project, an `api-version`, and OAuth respectively; filling only the API-key field does not configure them. + +## Add a custom provider + +Choose **Add a custom provider** for a company gateway, self-hosted server, or provider absent from the installed catalog. Supply a lowercase Provider ID, base URL, API protocol, credential, and at least one model. ![The custom provider form: Provider ID, display name, base URL, API protocol, and API key](providers-custom-form.png) -Every field but the Provider ID stays editable afterwards: **Edit** on the row reopens the same fields, with the display name and the protocol under **Customized settings** beside the base URL. Clearing the display name falls back to the Provider ID. The Provider ID itself is fixed: it names the route in requests, in `agent-default-model`, and in every session already logged, and it is the stem of the credential reference the page can never read back — so renaming a route means declaring a new provider and deleting the old one. +The Provider ID is permanent because requests, saved sessions, model defaults, and credential references use it. To rename a provider, add a new provider and delete the old one. The display name, base URL, protocol, credential, and models remain editable. -**Let the endpoint report its models.** Expand **Model catalog** and choose **Fetch available models**: the interrogation asks the endpoint **the form currently shows** — including a base URL edited but not yet saved and a key typed but not yet stored — and offers what it reports as candidates to pick from. A route the installed catalog describes is answered from that catalog with no network call. Adopting a candidate only writes rows into the draft; nothing is stored until you save. +Under **Model catalog**, choose **Fetch available models** to query the base URL and credential currently shown in the form. Selecting candidates updates the draft; the provider is not stored until you save. Catalog providers use their installed catalog without a network request. -Keys are write-only: the page only ever holds a redacted descriptor, never the literal secret. A key you enter is stored in `$DSH_HOME/.credentials.yaml`, and the profile records only the variable name that references it. +## Select a model -## settings.yaml for advanced configuration +Configured providers appear in the model picker. Selecting a model also makes it the default for new sessions. A session that has already sent a request retains the model recorded in its own log. -The document lives at `$DSH_HOME/settings.yaml` (`$DSH_HOME` defaults to `~/.dsh`). The Models page writes this file, and you can edit it directly; neither source outranks the other. - -```yaml -llm-deepseek: - reasoningEffort: high - -llm-pi-ai: - providers: - # Catalog route: endpoint, protocol, and models come from pi-ai; you supply - # the credential. - openai: - apiKeyEnv: OPENAI_API_KEY - - # Also a catalog route, moved to a private proxy, with its catalog narrowed - # to one model and that model's capacity corrected. Every unset field still - # comes from the catalog. - anthropic: - apiKeyEnv: ANTHROPIC_API_KEY - baseURL: https://proxy.example.com:8443 - reasoning: high - models: - - id: claude-sonnet-4-5 - contextWindow: 200000 - - # Catalog route with one model reshaped in place; the rest of the catalog - # keeps serving (a models list would replace it instead). - deepseek: - apiKeyEnv: DEEPSEEK_API_KEY - modelOverrides: - deepseek-v4-pro: - reasoningEfforts: - off: - high: high - - # Hand-declared route: pi-ai ships nothing under this key, so the profile - # supplies the whole provider. - acme-gateway: - displayName: Acme Gateway - apiKeyEnv: ACME_GATEWAY_API_KEY - api: openai-completions - baseURL: https://gateway.acme.example/v1 - # Reasoning dialect for an endpoint whose URL pi-ai cannot recognize. - compat: - thinkingFormat: deepseek - models: - - id: acme-large - name: Acme Large - contextWindow: 65536 - maxTokens: 4096 - - id: acme-think - name: Acme Think - # key = level offered in the picker, value = what goes on the wire; - # only off may leave the value empty (supported, send nothing). - reasoningEfforts: - off: - high: high - max: ultra -``` - -A settings section merges over the matching `cordis.yml` configuration **per provider**, so you can override one field of one route and leave the rest as the composition set them. - -A profile the adapter could not serve is refused **where it is written**: a hand-declared route needs `api`, `baseURL`, and at least one model, and a profile missing any of them fails naming the offending route and model rather than being stored and quietly disabling the whole namespace. When an already-stored document is broken by an external edit, settings keeps the last good value and warns. - -## The model catalog - -A profile's `models` list *replaces* that route's installed catalog rather than extending it; omitting it or leaving it empty serves the catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a route to two models, correcting one capacity, or adding a model newer than the installed catalog are each a one-line edit — but once you declare the list, every model the route should keep serving must appear in it, an entry of nothing but `id` being enough. - -Reshaping a few catalog models while keeping the rest is `modelOverrides`' job: it is keyed by catalog model id, takes the same fields a `models` entry does, and leaves the rest of the catalog serving untouched. An override naming a model the catalog does not describe — or set beside a `models` list, or on a custom provider — is refused rather than silently skipped. - -The configurable model fields are `id`, `name`, `contextWindow`, `maxTokens`, `reasoningEfforts`, and `compat`. Pricing and input modalities have no consumer and ride the installed entry. - -**Declare reasoning levels per model.** `reasoningEfforts` lists the levels a model offers: each key appears in the composer's effort picker, and its value is what dispatch sends on the wire — `high: high` passes the name through, `max: ultra` renames it for a gateway with its own vocabulary. A level you leave out is not offered. `off` is special: declared without a value, Off appears in the picker and selecting it sends nothing; left out entirely, the picker offers no Off and requests carry no off switch — the provider's own default decides. `reasoningEfforts: false` declares a non-reasoning model, which is also how you strip reasoning from a catalog model your gateway cannot serve. Without this field a custom model does not reason and a catalog model keeps its catalog levels. - -**Pick the reasoning dialect.** How a level travels — plain `reasoning_effort`, DeepSeek's `thinking: {type}` plus effort, and so on — is normally guessed from the endpoint URL, and a private gateway's URL says nothing, so a DeepSeek-style gateway would be spoken to in the OpenAI dialect. `compat.thinkingFormat` sets the dialect explicitly, and `compat.supportsReasoningEffort: false` holds the parameter back from an endpoint that rejects it; both work on the route (its models' default) or per model, for `openai-completions` routes only. - -A model neither the entry nor the catalog sizes takes the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768). Both are guesses by construction, which is why they are route fields: a deployment whose gateway serves smaller models corrects them once. - -Model ids are not lifecycle configuration. Requesting a model the route does not configure fails with `UNKNOWN_MODEL` before any provider request goes out. - -## Credentials - -Use `apiKeyEnv`: it is a *reference* resolved per request, so no secret enters the configuration file. Omitting it leaves a route unauthenticated, which for a catalog route means pi-ai's own environment discovery. A reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` rather than falling through to whatever unrelated key the environment happens to hold. - -Under `dsh`, references resolve from the inherited environment, the Models page's `$DSH_HOME/.credentials.yaml` store, the invoking directory's `.env`, then `$DSH_HOME/.env`. Without a credential service, a reference reads only the matching environment variable. One credential serves every model on its route. - -## Point an agent at the new provider - -A configured route appears in the web model picker and can be switched at any time. - -Switching there also sets the default: the model you pick becomes the one the next new session starts on, recorded in `settings.yaml` under `agent-default-model`. There is no separate gesture. - -```yaml -agent-default-model: - provider: acme-gateway - model: acme-large - reasoningEffort: high # optional -``` - -After a session has run a turn, its own log remains authoritative for its model selection; the default applies only to sessions without a recorded request. The shipped fallback under this section is the base bundle's `agent-default-model` composition entry (`deepseek-official` / `deepseek-v4-flash`). A self-assembled `cordis.yml` mounts and configures `@deepseek-ai/dsh-agent-default-model`; both direct entry points and Host-backed entry points read that same service. - -If the provider a saved default names is later removed, the composer says **Select model** and refuses input until you pick one, rather than sending to a route nothing serves. +If a saved default names a provider that was deleted, the composer displays **Select model** and blocks input until another model is selected. ## Troubleshooting -- **`MISSING_CREDENTIAL`** — the variable the profile's `apiKeyEnv` names holds no value. Store the key once through the Models page, or export the variable. -- **`UNKNOWN_MODEL`** — the requested model is not in the route's configured catalog. Add it to `models`, or use an id the catalog already carries. -- **`UNSUPPORTED_REASONING_EFFORT`** — the request asked the model for a level it does not offer. Pick a level the composer lists for that model, or declare the missing one in the model's `reasoningEfforts`. -- **`settings-rejected`** — the written profile cannot be served, and the message names the route and model. For a hand-declared route, check that `api`, `baseURL`, and `models` are all present. -- **Fetching available models answers 401** — the endpoint refused the interrogation. Check the key; if the base URL points at an Anthropic-style gateway, note that the interrogation reads only the OpenAI-compatible `GET /models`, so enter the models by hand instead. +- **`MISSING_CREDENTIAL`** — Store the provider key through the Models page or supply the referenced environment variable. +- **`UNKNOWN_MODEL`** — Select a configured model or add the missing model to the custom provider. +- **Fetching available models returns 401** — Check the key. Model discovery calls the OpenAI-compatible `GET /models` endpoint; enter models manually for endpoints that do not provide it. -## Exact field reference +## Advanced configuration -The complete fields, types, and defaults each plugin currently supports live in the generated [plugin configuration catalog](../../config-catalog.md). Each adapter's own semantics belong to its README: [`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) and [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md). For `cordis.yml` itself, see [Configuration](./config.md). +The generated [plugin configuration catalog](../../config-catalog.md) lists every supported field and default. The [`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) and [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md) references own direct `settings.yaml` configuration, catalog resolution, reasoning controls, credentials, and adapter errors. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index 060bf3dc41..7d74e0086e 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -2,151 +2,44 @@ [English](providers.md) | 中文 -Harness 出厂自带 DeepSeek,同时预装了一个通用的多提供方适配器,用来接入 pi-ai 已安装目录中的 Anthropic、OpenAI 等提供方,或任何 OpenAI 兼容的网关与自建服务。你有两个入口:Web 界面的**模型**页,以及 `$DSH_HOME/settings.yaml`。两者写的是同一份文档,改完下一次请求即生效,不用重启。 +本指南假定你已按照[根 README](../../../README.md#run)启动 Web UI。模型变更会在下一次请求时生效,不需要重启服务器。 -## 提供方从哪里来 +## 配置 DeepSeek -`cordis.yml` 决定装了哪些**适配器**,settings 文档决定跑哪些**提供方**。出厂组合里有两个 LLM 适配器: - -- `llm-deepseek` 提供 `deepseek-official` 路由,是默认可用的那个。 -- `llm-pi-ai` 以**休眠**状态挂载:零路由,模型选择器里也不会多出条目,直到 settings 里的 `llm-pi-ai:` 段落给出提供方 profile,路由才注册上来;段落清空则一并撤下。 - -因此新增一个提供方通常不需要改 `cordis.yml`,写 settings 就够了——而模型页做的正是这件事。 - -## 在 Web 界面里配置 - -启动 `pnpm dsh web`,打开**设置 → 模型**。 +打开**设置 → 模型**。DeepSeek 卡片提供一个 API 密钥字段;输入密钥并保存。 ![模型页:DeepSeek 卡片,以及添加提供方与添加自定义提供方两个入口](providers-models-page.zh.png) -**填 DeepSeek 的密钥。** DeepSeek 卡片上只有一个 API 密钥输入框,填好保存即可开始用。 +密钥是只写的。保存后,页面只会收到脱敏描述符,永远不会收到明文密钥。密钥存储在 `$DSH_HOME/.credentials.yaml` 中,settings 只保留它的凭据引用。 -**添加内置目录里的提供方。** 点**添加提供方**,从 pi-ai 内置目录中选一个(anthropic、openai 等),填入该提供方的 API 密钥。端点、协议和模型目录都由内置目录提供,你只需要给密钥。 +## 添加目录提供方 -只对以 API 密钥认证的提供方成立。目录里也有 Bedrock、Vertex、Azure、Codex:它们分别需要 AWS 凭据与区域、ADC 项目配置、`api-version`、OAuth,只填密钥框不会让它们工作——这类提供方靠 pi-ai 自己的环境发现认证,凭据按各自的原生方式准备。 +选择**添加提供方**,选取 Anthropic 或 OpenAI 等提供方,输入其 API 密钥并保存。已安装目录会提供端点、协议和模型列表。 -**添加自定义提供方。** 点**添加自定义提供方**,用于内置目录没有的路由——公司网关、自建服务,或比内置目录更新的提供方。需要填 Provider ID(请求里点名它、也作为凭据名的小写标识)、API 地址、协议,以及至少一个模型。 +使用原生认证的提供方需要各自的原生凭据。Bedrock、Vertex、Azure 和 Codex 分别使用 AWS 凭据与区域、ADC 项目、`api-version` 和 OAuth;只填写 API 密钥字段无法完成配置。 + +## 添加自定义提供方 + +对于公司网关、自建服务器或已安装目录中不存在的提供方,选择**添加自定义提供方**。提供小写 Provider ID、基础 URL、API 协议、凭据和至少一个模型。 ![自定义提供方表单:Provider ID、显示名称、API 地址、API 协议、API 密钥](providers-custom-form.zh.png) -除 Provider ID 外的每个字段之后都还能改:行上的**编辑**会重新打开这些字段,显示名称和协议在「自定义设置」里、紧挨着 API 地址;显示名称清空即退回 Provider ID。Provider ID 本身固定不可改:它在请求里、在 `agent-default-model` 里、在每一条已记录的会话里点名这条路由,同时还是凭据引用的词干,而页面永远读不回凭据值——因此重命名一条路由等于声明一个新提供方再把旧的删掉。 +Provider ID 是永久的,因为请求、已保存会话、模型默认值和凭据引用都会使用它。如需重命名提供方,请添加新提供方并删除旧提供方。显示名称、基础 URL、协议、凭据和模型仍可编辑。 -**让端点自己报模型。** 展开**模型目录**后点**获取可用模型**,会按你**当前表单里**的地址与密钥去问端点(地址改了但没保存、密钥刚输入还没存下,都算数),把它报告的模型列成候选让你勾选。内置目录里的路由直接由目录作答,不联网。采纳只是把行写进草稿,最终还是你点保存才落盘。 +在**模型目录**中选择**获取可用模型**,可查询表单当前显示的基础 URL 和凭据。选择候选项只会更新草稿;保存前不会存储提供方。目录提供方使用已安装目录,不发起网络请求。 -密钥是只写的:页面拿到的永远是脱敏描述符,不是明文。写入的密钥存进 `$DSH_HOME/.credentials.yaml`,profile 里只记录引用它的变量名。 +## 选择模型 -## settings.yaml:进阶配置 +已配置的提供方会出现在模型选择器中。选择模型也会将其设为新会话的默认值。已发送过请求的会话会保留自身日志中记录的模型。 -文档位于 `$DSH_HOME/settings.yaml`(`$DSH_HOME` 默认是 `~/.dsh`)。模型页写的就是这个文件,你也可以直接编辑它——两个来源没有主次之分。 - -```yaml -llm-deepseek: - reasoningEffort: high - -llm-pi-ai: - providers: - # Catalog route: endpoint, protocol, and models come from pi-ai; you supply - # the credential. - openai: - apiKeyEnv: OPENAI_API_KEY - - # Also a catalog route, moved to a private proxy, with its catalog narrowed - # to one model and that model's capacity corrected. Every unset field still - # comes from the catalog. - anthropic: - apiKeyEnv: ANTHROPIC_API_KEY - baseURL: https://proxy.example.com:8443 - reasoning: high - models: - - id: claude-sonnet-4-5 - contextWindow: 200000 - - # Catalog route with one model reshaped in place; the rest of the catalog - # keeps serving (a models list would replace it instead). - deepseek: - apiKeyEnv: DEEPSEEK_API_KEY - modelOverrides: - deepseek-v4-pro: - reasoningEfforts: - off: - high: high - - # Hand-declared route: pi-ai ships nothing under this key, so the profile - # supplies the whole provider. - acme-gateway: - displayName: Acme Gateway - apiKeyEnv: ACME_GATEWAY_API_KEY - api: openai-completions - baseURL: https://gateway.acme.example/v1 - # Reasoning dialect for an endpoint whose URL pi-ai cannot recognize. - compat: - thinkingFormat: deepseek - models: - - id: acme-large - name: Acme Large - contextWindow: 65536 - maxTokens: 4096 - - id: acme-think - name: Acme Think - # key = level offered in the picker, value = what goes on the wire; - # only off may leave the value empty (supported, send nothing). - reasoningEfforts: - off: - high: high - max: ultra -``` - -settings 段落**逐个提供方**地盖在 `cordis.yml` 的同名配置之上,所以你可以只覆盖某个路由的一个字段,其余保持组合里的样子。 - -一份服务不了的 profile 会在**写入处**被拒绝:手工声明的路由必须给出 `api`、`baseURL` 和至少一个模型,缺了会带着路由名和模型名报错,而不是存下来再让整个命名空间静默失效。已经存好的文档被外部改坏时,settings 会保留上一次的好值并告警。 - -## 模型目录 - -`models` 是**替换**该路由的内置目录,不是往里追加;省略或留空则原样使用内置目录。每个条目会从同 `id` 的内置模型继承自己没写的字段,所以「收窄到两个模型」「更正一个容量」「加一个比内置目录更新的模型」都是一行编辑——但一旦声明了这份列表,该路由要继续服务的每个模型就都必须出现在其中,条目哪怕只写一个 `id` 也足够。 - -就地重塑目录里的几个模型、保留其余,归 `modelOverrides` 管:它以目录模型 id 为键,接受与 `models` 条目相同的字段,目录的其余部分原样继续服务。覆盖若点名了目录没有描述的模型,或与 `models` 列表并存,或写在自定义提供方上,都会被拒绝,而不是被静默跳过。 - -可配置的模型字段是 `id`、`name`、`contextWindow`、`maxTokens`、`reasoningEfforts` 与 `compat`。定价与输入模态没有消费方,随内置目录条目走。 - -**按模型声明推理档位。** `reasoningEfforts` 列出模型提供的档位:每个键都会出现在输入框的档位选择器里,其值是分派在协议中实际发送的内容——`high: high` 原样透传名称,`max: ultra` 则为使用自有词汇的网关改名。没写的档位不会被提供。`off` 比较特殊:声明而不给值,选择器里会出现 Off,选中它时什么也不发送;完全不写,选择器不提供 Off,请求也不携带关闭开关——由提供方自己的默认行为决定。`reasoningEfforts: false` 声明一个不具备推理能力的模型,这也是从网关服务不了的目录模型上剥除推理的办法。不写这个字段,自定义模型不推理,目录模型保留目录给出的档位。 - -**选定推理方言。** 档位如何在协议中传输——单独一个 `reasoning_effort`、DeepSeek 的 `thinking: {type}` 加档位,诸如此类——通常靠端点 URL 来猜,而私有网关的 URL 什么也说明不了,于是 DeepSeek 风格的网关只会收到 OpenAI 方言的请求。`compat.thinkingFormat` 用来显式指定方言,`compat.supportsReasoningEffort: false` 则让该参数不再发给拒绝它的端点;两者既可设在路由上(作为其模型的默认值),也可按模型设置,且仅适用于 `openai-completions` 路由。 - -两处容量都没给出的模型,取路由级兜底 `defaultContextWindow`(262144)与 `defaultMaxTokens`(32768)。这两个数按定义就是猜测,所以它们是路由字段:网关服务的模型更小时改一次即可。 - -模型 id 不是生命周期配置:请求一个该路由没有配置的模型,会在任何网络请求之前以 `UNKNOWN_MODEL` 失败。 - -## 凭据 - -使用 `apiKeyEnv`——它是一个**引用**,每次请求时解析,密钥本身不进配置文件。省略它会让路由不带认证,对内置目录路由意味着交给 pi-ai 自己的环境发现。给了引用却解析不到,请求会以 `MISSING_CREDENTIAL` 失败,而不是退回去用环境里碰巧存在的某个不相干的 key 计费。 - -在 `dsh` 下,引用依次从继承环境、模型页的 `$DSH_HOME/.credentials.yaml` 存储、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析。未挂载凭据服务时,引用只读取同名环境变量。一份凭据供该路由上的所有模型使用。 - -## 让 agent(智能体)用上新提供方 - -配好的路由会出现在 Web 的模型选择器里,随时可切。 - -在那里切换同时也就选定了默认值:你选的模型会成为下一个新会话的起点,记录在 `settings.yaml` 的 `agent-default-model` 段里。没有另一个单独的手势。 - -```yaml -agent-default-model: - provider: acme-gateway - model: acme-large - reasoningEffort: high # optional -``` - -会话跑过一轮后,其自身日志仍是模型选择的权威;默认值只适用于尚无请求记录的会话。这个段落之下的出厂兜底是 base 组合包的 `agent-default-model` 组合条目(`deepseek-official` / `deepseek-v4-flash`)。自行组装的 `cordis.yml` 会挂载并配置 `@deepseek-ai/dsh-agent-default-model`;直接入口与 Host 支撑的入口都读取同一服务。 - -如果某个已存默认值指向的提供方后来被删掉了,输入框会显示**选择模型**并拒绝输入,而不是把消息发给一个没人服务的路由。 +如果已保存默认值指向已删除的提供方,输入框会显示**选择模型**,并在选择其他模型前阻止输入。 ## 排错 -- **`MISSING_CREDENTIAL`** — profile 里的 `apiKeyEnv` 指向的变量没有值。用模型页存一次密钥,或导出该环境变量。 -- **`UNKNOWN_MODEL`** — 请求的模型不在该路由配置的目录里。把它加进 `models`,或改用目录里已有的 id。 -- **`UNSUPPORTED_REASONING_EFFORT`** — 请求向模型要了一个它不提供的档位。从输入框为该模型列出的档位里挑一个,或把缺的那个声明进该模型的 `reasoningEfforts`。 -- **`settings-rejected`** — 写入的 profile 服务不了,错误信息会点名具体的路由和模型。手工声明的路由检查 `api`、`baseURL`、`models` 是否齐全。 -- **获取可用模型返回 401** — 端点拒绝了这次探测。检查密钥;若地址指向的是 Anthropic 风格网关,注意探测只读 OpenAI 兼容的 `GET /models`,此时手工填写模型即可。 +- **`MISSING_CREDENTIAL`**:通过模型页存储提供方密钥,或提供被引用的环境变量。 +- **`UNKNOWN_MODEL`**:选择已配置的模型,或向自定义提供方添加缺失的模型。 +- **获取可用模型返回 401**:检查密钥。模型发现会调用 OpenAI 兼容的 `GET /models` 端点;对于不提供该端点的服务,请手动输入模型。 -## 精确字段参考 +## 进阶配置 -每个插件当前支持的完整字段、类型与默认值见自动生成的[插件配置目录](../../config-catalog.md)。两个适配器各自的语义由它们的 README 负责:[`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) 与 [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md)。`cordis.yml` 本身的写法见[配置文件](./config.md)。 +自动生成的[插件配置目录](../../config-catalog.md)列出所有受支持的字段与默认值。[`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) 和 [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md) 参考文档负责直接 `settings.yaml` 配置、目录解析、推理控制、凭据与适配器错误。 diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml index e223e8ec2f..11d3323bad 100644 --- a/docs/user/guide/python-sdk.i18n.yaml +++ b/docs/user/guide/python-sdk.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/python-sdk.md -python-sdk.md: c6aee27e08b266ae3e54f7817cc5b9689ad8fba4 -python-sdk.zh.md: 0b0e37a6fff8ee11d4694163ecb7d22f93bcd550 +python-sdk.md: 3ef0e6595b0b5b7dddfe05e659c58556dcc48874 +python-sdk.zh.md: a46c79aa0c7cd3b6a286e1f64e01a8a81496c0f0 diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md index c6aee27e08..3ef0e6595b 100644 --- a/docs/user/guide/python-sdk.md +++ b/docs/user/guide/python-sdk.md @@ -2,59 +2,29 @@ English | [中文](python-sdk.zh.md) -This tutorial installs the Python SDK, runs a checked-in Cordis composition without the Web UI, and uses the same API in your own program. It uses the compact [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml) configuration as a complete example with a configurable system prompt, a two-tool catalog, persistent-shell behavior, and context compaction disabled. +This tutorial is the programmatic alternative to the Web UI. It installs the published Python SDK, runs a checked-in agent composition, and shows how to call the same API from your own program. ## Prerequisites - Python 3.10 or newer +- Git - Linux x64, Linux arm64, or macOS arm64 - A DeepSeek-compatible API endpoint and credential - An isolated workspace that the agent may modify ## Install the SDK -Choose either the public package or a source build. Both install the `deepseek-harness-sdk` distribution and expose the `deepseek_harness` Python module. - -### Install from PyPI - -Create a virtual environment and install the SDK with its same-version bundled runtime: +Clone the repository for its runnable example, create a virtual environment, and install the SDK with its same-version bundled runtime: ```sh +git clone https://github.com/deepseek-harness/deepseek-harness.git +cd deepseek-harness python -m venv .venv . .venv/bin/activate python -m pip install deepseek-harness-sdk ``` -### Build from source - -A source build additionally requires Git, Node.js ^22.19 or >= 24, Corepack-enabled pnpm 11, and `uv`. The following commands build the runtime for the current supported host platform, build both wheels, and install them into the active virtual environment: - -```sh -git clone https://github.com/deepseek-ai/deepseek-harness.git deepseek-harness -cd deepseek-harness -python -m pip install uv==0.11.23 -corepack enable -pnpm install - -case "$(uname -s):$(uname -m)" in - Linux:x86_64) runtime_platform=linux-x64 ;; - Linux:aarch64|Linux:arm64) runtime_platform=linux-arm64 ;; - Darwin:arm64) runtime_platform=macos-arm64 ;; - *) echo "unsupported platform" >&2; exit 1 ;; -esac - -pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets="node24-$runtime_platform" -version="$(node -p "require('./package.json').version")" -python scripts/build-python-release.py --package sdk --output-dir dist-python -python scripts/build-python-release.py \ - --package runtime \ - --platform "$runtime_platform" \ - --runtime-exe "dist-exe/dsh-jsonrpc-agent-pkg-$runtime_platform" \ - --output-dir dist-python -python -m pip install --find-links dist-python "deepseek-harness-sdk==$version" -``` - -The runtime wheel contains the JSON-RPC executable and every plugin used by the complete [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml), so neither installation path needs Node.js after installation. +The installed runtime needs no system Node.js. Repository contributors who need to build the runtime or wheels from source should use the [Python contributor workflows](../../../python/development.md). ## Run the checked-in example @@ -67,7 +37,7 @@ export DEEPSEEK_API_KEY=sk-your-key-here # export DSH_SYSTEM_PROMPT='You are a helpful software engineer assistant.' ``` -Run one task from the repository checkout: +Run one task against an isolated workspace and session directory: ```sh python examples/jsonrpc-agent/minimal.py \ @@ -77,11 +47,11 @@ python examples/jsonrpc-agent/minimal.py \ "Inspect the repository and fix the failing tests." ``` -The script prints the final assistant response. The session root receives a JSONL session log containing the assembled model request and every tool call. +The script prints the final assistant response. The session directory receives a JSONL log containing the assembled model requests and tool calls. ## Use the SDK in your own program -The example is a thin wrapper around this SDK call: +The checked-in example is a thin wrapper around this SDK call: ```python from pathlib import Path @@ -108,9 +78,9 @@ with DeepSeekHarness( print(result.final_response) ``` -`DeepSeekHarness` starts the bundled JSON-RPC runtime lazily and reuses it until the context manager exits. Reusing the same harness and session id across calls also preserves the session-owned Bash process, including its working directory, exported variables, and shell functions. +`DeepSeekHarness` starts the bundled runtime lazily and reuses it until the context manager exits. Reusing the same harness and session id preserves the session-owned Bash process, including its working directory, exported variables, and shell functions. Use a fresh session id for an independent task; reuse an id only when the next call should continue the same durable conversation. -## Understand the example configuration +## Understand the example composition | Property | Value | |---|---| @@ -123,7 +93,7 @@ print(result.final_response) | Filesystem | Bare local backend; absolute editor paths may address any path visible to the runtime process | | Session persistence | Uncompressed JSONL under `DSH_SESSION_ROOT` | -The configuration omits harness identity, workspace prompt text, skills, one-shot Bash, task tools, compaction, and every other model-facing plugin. Sandbox-policy facts are logged as runtime user context rather than appended to the system prompt. The editor requires absolute paths as an unconditional current contract, so the obsolete `requireAbsolutePath` option is absent. +The composition omits harness identity, workspace prompt text, skills, one-shot Bash, task tools, compaction, and every other model-facing plugin. Sandbox-policy facts are logged as runtime user context rather than appended to the system prompt. ## Choose workspace and session IDs @@ -131,4 +101,4 @@ The configuration omits harness identity, workspace prompt text, skills, one-sho The composition uses `danger-full-access`. Run it only inside a disposable checkout or container: Bash and the editor can modify any path allowed to the runtime process. The persistent PTY backend requires a POSIX terminal substrate, so this composition does not support Windows agents. -For the complete SDK lifecycle and result contract, see the [Python SDK reference](../../../python/sdk/README.md). For Cordis composition syntax, see [Configuration](./config.md). +The [`jsonrpc-agent` example reference](../../../examples/jsonrpc-agent/README.md) owns the exact composition. The [Python SDK reference](../../../python/sdk/README.md) covers lifecycle, results, notifications, runtime selection, and configuration; the [Cordis primer](../../cordis-primer.md) covers composition syntax. diff --git a/docs/user/guide/python-sdk.zh.md b/docs/user/guide/python-sdk.zh.md index 0b0e37a6ff..a46c79aa0c 100644 --- a/docs/user/guide/python-sdk.zh.md +++ b/docs/user/guide/python-sdk.zh.md @@ -2,59 +2,29 @@ [English](python-sdk.md) | 中文 -本教程介绍如何安装 Python SDK、在不使用 Web UI 的情况下运行仓库内置 Cordis 组合,以及如何在自己的程序中调用同一套 API。教程使用精简且完整的 [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml) 作为示例,其中包含可配置的系统提示词、双工具目录和持久 shell 行为,并关闭上下文压缩(context compaction)。 +本教程介绍 Web UI 之外的程序化使用方式:安装已发布的 Python SDK、运行仓库内置的 agent(智能体)组合,并在自己的程序中调用同一套 API。 ## 前置要求 - Python 3.10 或更高版本 +- Git - Linux x64、Linux arm64 或 macOS arm64 - DeepSeek 兼容的 API 端点与凭据 - agent 可以修改的隔离 workspace ## 安装 SDK -可以选择安装公开包或从源码构建。两种方式都会安装 `deepseek-harness-sdk` 分发包,并提供 `deepseek_harness` Python 模块。 - -### 从 PyPI 安装 - -请创建虚拟环境,并安装 SDK 及其同版本内置运行时: +克隆仓库以使用其中的可运行示例,创建虚拟环境,并安装 SDK 及其同版本内置运行时: ```sh +git clone https://github.com/deepseek-harness/deepseek-harness.git +cd deepseek-harness python -m venv .venv . .venv/bin/activate python -m pip install deepseek-harness-sdk ``` -### 从源码构建 - -从源码构建还需要 Git、Node.js ^22.19 或 >= 24、通过 Corepack 启用的 pnpm 11,以及 `uv`。以下命令为当前受支持的宿主平台构建运行时和两个 wheel 包,并将它们安装进当前虚拟环境: - -```sh -git clone https://github.com/deepseek-ai/deepseek-harness.git deepseek-harness -cd deepseek-harness -python -m pip install uv==0.11.23 -corepack enable -pnpm install - -case "$(uname -s):$(uname -m)" in - Linux:x86_64) runtime_platform=linux-x64 ;; - Linux:aarch64|Linux:arm64) runtime_platform=linux-arm64 ;; - Darwin:arm64) runtime_platform=macos-arm64 ;; - *) echo "unsupported platform" >&2; exit 1 ;; -esac - -pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets="node24-$runtime_platform" -version="$(node -p "require('./package.json').version")" -python scripts/build-python-release.py --package sdk --output-dir dist-python -python scripts/build-python-release.py \ - --package runtime \ - --platform "$runtime_platform" \ - --runtime-exe "dist-exe/dsh-jsonrpc-agent-pkg-$runtime_platform" \ - --output-dir dist-python -python -m pip install --find-links dist-python "deepseek-harness-sdk==$version" -``` - -运行时 wheel 包含 JSON-RPC 可执行文件,以及完整 [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml) 使用的每个插件,因此两种安装方式完成后都不再需要 Node.js。 +安装后的运行时不需要系统提供 Node.js。需要从源码构建运行时或 wheel 包的仓库贡献者应使用 [Python 贡献者工作流](../../../python/development.md)。 ## 运行仓库内置示例 @@ -67,7 +37,7 @@ export DEEPSEEK_API_KEY=sk-your-key-here # export DSH_SYSTEM_PROMPT='You are a helpful software engineer assistant.' ``` -从仓库 checkout 运行一个任务: +针对隔离的 workspace 和会话目录运行一个任务: ```sh python examples/jsonrpc-agent/minimal.py \ @@ -77,11 +47,11 @@ python examples/jsonrpc-agent/minimal.py \ "Inspect the repository and fix the failing tests." ``` -脚本会打印 assistant 的最终回复。会话根目录会收到 JSONL 会话日志,其中包含组装后的模型请求与每次工具调用。 +脚本会打印 assistant 的最终回复。会话目录会收到 JSONL 日志,其中包含组装后的模型请求与工具调用。 ## 在自己的程序中使用 SDK -该示例是以下 SDK 调用的轻量包装层: +仓库内置示例是以下 SDK 调用的轻量包装: ```python from pathlib import Path @@ -108,9 +78,9 @@ with DeepSeekHarness( print(result.final_response) ``` -`DeepSeekHarness` 会延迟启动内置 JSON-RPC 运行时,并持续复用,直至退出上下文管理器。在多次调用中复用同一个 harness 和 session id,还会保留该会话拥有的 Bash 进程,包括其工作目录、已导出的变量与 shell 函数。 +`DeepSeekHarness` 会延迟启动内置运行时,并持续复用,直至退出上下文管理器。复用同一个 harness 与 session id 会保留该会话拥有的 Bash 进程,包括其工作目录、已导出的变量与 shell 函数。独立任务应使用新的 session id;只有下一次调用需要延续同一段持久化对话时,才复用原有 id。 -## 了解示例配置 +## 了解示例组合 | 属性 | 值 | |---|---| @@ -123,7 +93,7 @@ print(result.final_response) | 文件系统 | 裸本地后端;编辑器使用绝对路径,可以访问运行时进程可见的任何路径 | | 会话持久化 | `DSH_SESSION_ROOT` 下未压缩的 JSONL | -该配置省略了 harness 身份、workspace 提示词文本、skill(技能)、一次性 Bash、任务工具、上下文压缩和其他所有面向模型的插件。沙箱策略事实记录为运行时用户上下文,而不会追加到系统提示词中。编辑器无条件要求绝对路径,因此配置中没有已经废弃的 `requireAbsolutePath` 选项。 +该组合省略了 harness 身份、workspace 提示词文本、skill(技能)、一次性 Bash、任务工具、上下文压缩和其他所有面向模型的插件。沙箱策略事实记录为运行时用户上下文,而不会追加到系统提示词中。 ## 选择 workspace 与 session id @@ -131,4 +101,4 @@ print(result.final_response) 该组合使用 `danger-full-access`。只能在可丢弃的 checkout 或容器内运行:Bash 与编辑器可以修改运行时进程有权访问的任何路径。持久 PTY 后端需要 POSIX 终端环境,因此该组合不支持 Windows agent。 -完整的 SDK 生命周期与结果约定见 [Python SDK 参考](../../../python/sdk/README.md)。Cordis 组合语法见[配置](./config.md)。 +准确的组合内容归 [`jsonrpc-agent` 示例参考](../../../examples/jsonrpc-agent/README.md)所有。[Python SDK 参考](../../../python/sdk/README.md)介绍生命周期、结果、通知、运行时选择和配置;[Cordis primer](../../cordis-primer.md)介绍组合语法。 diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml deleted file mode 100644 index 0cca002d4f..0000000000 --- a/docs/user/guide/quickstart.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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 docs/user/guide/quickstart.md -quickstart.md: e93e5a430f0cb345728581cd6fa3175ffd20b7d1 -quickstart.zh.md: 69cde830bb802ef19cc1204685395b957a0e02e3 diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md deleted file mode 100644 index e93e5a430f..0000000000 --- a/docs/user/guide/quickstart.md +++ /dev/null @@ -1,62 +0,0 @@ -# Quick start - -English | [中文](quickstart.zh.md) - -This guide gets an agent running in five minutes. - -## Prerequisites - -- [Node.js](https://nodejs.org/) ^22.19 or >= 24 -- [pnpm](https://pnpm.io/) 11 through Corepack -- A [DeepSeek Platform](https://platform.deepseek.com/) API key - -```sh -node -v -corepack enable -pnpm -v -``` - -## Step 1: install and configure the API key - -```sh -git clone https://github.com/deepseek-ai/deepseek-harness.git -cd deepseek-harness -pnpm install -``` - -Create the gitignored repository-root `.env`: - -```sh -DEEPSEEK_API_KEY=sk-your-key-here -``` - -## Step 2: run one Headless task - -Run a non-interactive task and print its final answer: - -```sh -pnpm dsh --profile headless "summarize the architecture of this workspace" -``` - -`dsh --profile headless` creates and persists a fresh session, prints the final assistant answer, and exits. It starts no Web server or listening port, and a successful run leaves stderr empty. - -## Step 3: use the Web UI - -Start the browser interface: - -```sh -pnpm dsh web -``` - -Open `http://127.0.0.1:3080`. The agent can read and write files, run commands, delegate subtasks, and track a plan. Try: `Create hello.js in the current directory, print "Hello from Harness!", and run it`. - -## What happened - -`dsh --profile headless` boots the `headless` profile: [`dsh-base`](../../../packages/bundle/base/cordis.patch.yml) and [`dsh-headless`](../../../packages/bundle/headless/cordis.patch.yml) compose over an empty root, then the runner drives the core Agent and Session services directly. `dsh web` instead composes `dsh-base` with [`dsh-web-app`](../../../packages/bundle/web-app/cordis.patch.yml), which owns the Host, HTTP, and browser layers. Both read the same default DeepSeek model route from `dsh-base`. - -## Next steps - -- [Get started with the Python SDK](./python-sdk.md) — install the SDK and run a complete Cordis configuration without the Web UI -- [Configure models](./providers.md) — reach providers beyond DeepSeek, and custom gateways -- [Configuration](./config.md) — understand the `cordis.yml` format -- [Develop a plugin](../develop/basic/) — build your own tool or backend diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md deleted file mode 100644 index 69cde830bb..0000000000 --- a/docs/user/guide/quickstart.zh.md +++ /dev/null @@ -1,62 +0,0 @@ -# 快速开始 - -[English](quickstart.md) | 中文 - -本指南带你在 5 分钟内跑起一个 agent(智能体)。 - -## 环境准备 - -- [Node.js](https://nodejs.org/) ^22.19 或 >= 24 -- 通过 Corepack 使用 [pnpm](https://pnpm.io/) 11 -- [DeepSeek Platform](https://platform.deepseek.com/) API 密钥 - -```sh -node -v -corepack enable -pnpm -v -``` - -## 第一步:安装并配置 API 密钥 - -```sh -git clone https://github.com/deepseek-ai/deepseek-harness.git -cd deepseek-harness -pnpm install -``` - -在仓库根目录创建已被 Git 忽略的 `.env`: - -```sh -DEEPSEEK_API_KEY=sk-your-key-here -``` - -## 第二步:运行一个 Headless 任务 - -运行一个非交互式任务并打印最终回答: - -```sh -pnpm dsh --profile headless "summarize the architecture of this workspace" -``` - -`dsh --profile headless` 创建并持久化一个新会话,打印最终助手回答,然后退出。它不会启动 Web 服务器或监听端口;成功运行时 stderr 为空。 - -## 第三步:使用 Web UI - -启动浏览器界面: - -```sh -pnpm dsh web -``` - -打开 `http://127.0.0.1:3080`。agent 可以读写文件、运行命令、分配子任务和跟踪计划。可以尝试:`Create hello.js in the current directory, print "Hello from Harness!", and run it`。 - -## 运行原理 - -`dsh --profile headless` 启动 `headless` profile:[`dsh-base`](../../../packages/bundle/base/cordis.patch.yml) 和 [`dsh-headless`](../../../packages/bundle/headless/cordis.patch.yml) 在空根之上组合,随后 runner 直接驱动 core Agent 与 Session 服务。`dsh web` 则由 `dsh-base` 与 [`dsh-web-app`](../../../packages/bundle/web-app/cordis.patch.yml) 组合,后者拥有 Host、HTTP 与浏览器层。二者都从 `dsh-base` 读取同一个默认 DeepSeek 模型路由。 - -## 下一步 - -- [Python SDK 快速上手](./python-sdk.md) — 安装 SDK,并在不使用 Web UI 的情况下运行完整 Cordis 配置 -- [配置模型](./providers.md) — 接入 DeepSeek 之外的提供方与自定义网关 -- [配置文件](./config.md) — 了解 `cordis.yml` 的格式 -- [开发插件](../develop/basic/) — 编写自己的工具或后端 diff --git a/examples/jsonrpc-agent/README.i18n.yaml b/examples/jsonrpc-agent/README.i18n.yaml index 45f02f37d4..d5c070643d 100644 --- a/examples/jsonrpc-agent/README.i18n.yaml +++ b/examples/jsonrpc-agent/README.i18n.yaml @@ -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 examples/jsonrpc-agent/README.md -README.md: 9eb37fd29442dc40c7a17cd266c225e6750a6886 -README.zh.md: f358d3c8b22017a10dff62ce2dedced2bfd6de6c +README.md: 967f3f499962bf1fd1873fc16ac8fd8075b0df3b +README.zh.md: f84ab95132e820cf0fcf45bff4ae30d9bccb55c1 diff --git a/examples/jsonrpc-agent/README.md b/examples/jsonrpc-agent/README.md index 9eb37fd294..967f3f4999 100644 --- a/examples/jsonrpc-agent/README.md +++ b/examples/jsonrpc-agent/README.md @@ -35,4 +35,6 @@ Pass the config path through the Python SDK's `cordis` option or `DSH_CORDIS_CON - owner-scoped persistent `bash` - `str_replace_editor` with `view`, `create`, `str_replace`, and `insert` -It composes the local PTY, bare `fs-local` backend, danger-full-access policy for persistent Bash, and uncompressed JSONL persistence needed by the bundled runtime. [`minimal.py`](minimal.py) runs it through the Python SDK and uses `DSH_MODEL` as its default model; the [Python SDK tutorial](../../docs/user/guide/python-sdk.md) covers setup, session management, and the security boundary. +It composes the local PTY, bare `fs-local` backend, danger-full-access policy for persistent Bash, and uncompressed JSONL persistence needed by the bundled runtime. Bash and absolute editor paths can modify any path available to the runtime process, so run this variant only against a disposable checkout or container. The persistent PTY requires a POSIX terminal environment and is not a Windows agent interface. + +[`minimal.py`](minimal.py) runs the composition through the Python SDK and uses `DSH_MODEL` as its default model. The [Python SDK tutorial](../../docs/user/guide/python-sdk.md) covers installation, execution, workspace selection, and session identity; the [SDK reference](../../python/sdk/README.md) owns runtime lifecycle and result semantics. diff --git a/examples/jsonrpc-agent/README.zh.md b/examples/jsonrpc-agent/README.zh.md index f358d3c8b2..f84ab95132 100644 --- a/examples/jsonrpc-agent/README.zh.md +++ b/examples/jsonrpc-agent/README.zh.md @@ -35,4 +35,6 @@ - 所有者作用域内持久化的 `bash` - 提供 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor` -它组合了内置运行时所需的本地 PTY、裸 `fs-local` 后端、供持久 Bash 使用的 danger-full-access 策略,以及未压缩的 JSONL 持久化。[`minimal.py`](minimal.py) 通过 Python SDK 运行该配置,并把 `DSH_MODEL` 作为默认模型;[Python SDK 教程](../../docs/user/guide/python-sdk.md)以此配置介绍设置方式、会话管理与安全边界。 +它组合了内置运行时所需的本地 PTY、裸 `fs-local` 后端、供持久 Bash 使用的 danger-full-access 策略,以及未压缩的 JSONL 持久化。Bash 和编辑器绝对路径可以修改运行时进程有权访问的任何路径,因此只能针对可丢弃的 checkout 或容器运行该变体。持久 PTY 需要 POSIX 终端环境,因此不适用于 Windows agent 接口。 + +[`minimal.py`](minimal.py)通过 Python SDK 运行该组合,并把 `DSH_MODEL` 作为默认模型。[Python SDK 教程](../../docs/user/guide/python-sdk.md)介绍安装、运行、workspace 选择与 session 标识;[SDK 参考](../../python/sdk/README.md)归属运行时生命周期与结果语义。 diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts similarity index 100% rename from packages/api/gateway/tests/client.spec.ts rename to packages/api/gateway/tests/gateway.client.spec.ts diff --git a/packages/api/gateway/tests/gateway.spec.ts b/packages/api/gateway/tests/gateway.host.spec.ts similarity index 100% rename from packages/api/gateway/tests/gateway.spec.ts rename to packages/api/gateway/tests/gateway.host.spec.ts diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml index 9d30c65bb8..22a80a7e13 100644 --- a/packages/boot/cmdline/README.i18n.yaml +++ b/packages/boot/cmdline/README.i18n.yaml @@ -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/cmdline/README.md -README.md: 2e8e58b23785fa78bd2663a459817669309a81be -README.zh.md: c04d76905edb4afa6b18b36b8284b14990be6bdd +README.md: 33125014539e801dbd2952a3b4513cafc80bdcee +README.zh.md: 7ef49a1027d3c17817c9171e1166ed6feecd8559 diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md index 2e8e58b237..3312501453 100644 --- a/packages/boot/cmdline/README.md +++ b/packages/boot/cmdline/README.md @@ -15,15 +15,16 @@ An embedding host with no command line provides an empty list; that is the hones ## Ordinary providers and injected config -Any app plugin may inject `cmdlineArgs`, parse it, and publish an ordinary app-owned service. `parseCmdline(ctx, program, plan)` is only a commander adapter; the caller owns the returned value and service: +Any app plugin may inject `cmdlineArgs`, parse it, and publish an ordinary app-owned service. `parseCmdline(ctx, program)` is only a commander adapter; the program's own action owns validation and the published service: ```ts ignore export const name = 'web-startup' export const inject = ['cmdlineArgs'] export function apply(ctx: Context): void { - const values = parseCmdline(ctx, webCommand(), planWebStartup) - if (values !== undefined) ctx.provide('webStartup', values) + const program = webCommand() + program.action(() => ctx.provide('webStartup', webValuesFrom(program))) + parseCmdline(ctx, program) } ``` @@ -45,7 +46,7 @@ Every row configured from those values uses ordinary service injection and direc port: !!js ctx.webStartup.port ?? 3080 ``` -`parseCmdline` parses the immutable arguments and asks `plan` for the app-owned value. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text, requests exit, and returns `undefined`; the provider publishes nothing, so dependent rows never activate. +`parseCmdline` refuses at load a program in which no command declares an action, routes every command's exit and output through the launcher (commander copies those settings into subcommands only at registration), and parses the immutable arguments; commander runs the invoked command's synchronous action on success. An action rejects an invalid invocation with `program.error(...)` — before publishing, since statements ahead of the rejection have already run. On `--help`, `--version`, a parse error, or that rejection, the helper writes commander's text and requests exit; the provider publishes nothing, so dependent rows never activate. ### How injection orders config diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md index c04d76905e..7ef49a1027 100644 --- a/packages/boot/cmdline/README.zh.md +++ b/packages/boot/cmdline/README.zh.md @@ -15,15 +15,16 @@ dsh 启动器交给它所引导应用的那条命令行。启动器只解析属 ## 普通提供方与注入配置 -任何应用插件都可以注入 `cmdlineArgs`、解析它,再发布一个普通的应用自有服务。`parseCmdline(ctx, program, plan)` 只适配 commander;返回值与服务都归调用方持有: +任何应用插件都可以注入 `cmdlineArgs`、解析它,再发布一个普通的应用自有服务。`parseCmdline(ctx, program)` 只适配 commander;校验与发布的服务都归 program 自己的 action 持有: ```ts ignore export const name = 'web-startup' export const inject = ['cmdlineArgs'] export function apply(ctx: Context): void { - const values = parseCmdline(ctx, webCommand(), planWebStartup) - if (values !== undefined) ctx.provide('webStartup', values) + const program = webCommand() + program.action(() => ctx.provide('webStartup', webValuesFrom(program))) + parseCmdline(ctx, program) } ``` @@ -45,7 +46,7 @@ export function apply(ctx: Context): void { port: !!js ctx.webStartup.port ?? 3080 ``` -`parseCmdline` 解析不可变参数,再向 `plan` 索取应用自有取值。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 文本、请求退出并返回 `undefined`;提供方什么也不发布,因此依赖行不会激活。 +`parseCmdline` 在加载时拒绝整棵命令树中没有任何命令声明 action 的 program,把每个命令的退出与输出都接到启动器上(commander 只在注册时把这些设置复制进子命令),再解析不可变参数;解析成功时 commander 运行被调用命令的同步 action。action 用 `program.error(...)` 拒绝无效调用——必须先拒绝后发布,因为写在拒绝之前的语句已经执行。遇到 `--help`、`--version`、解析错误或这种拒绝时,该适配器输出 commander 文本并请求退出;提供方什么也不发布,因此依赖行不会激活。 ### 注入如何排列配置求值 diff --git a/packages/boot/cmdline/src/index.ts b/packages/boot/cmdline/src/index.ts index ebe8d95aee..c053dcb95f 100644 --- a/packages/boot/cmdline/src/index.ts +++ b/packages/boot/cmdline/src/index.ts @@ -8,7 +8,8 @@ * text, and its parse errors instead of the launcher knowing them. * * Any app plugin can inject `cmdlineArgs` and call {@link parseCmdline}. A - * provider may publish the parsed values as its own service, and ordinary rows + * provider may publish the parsed values as its own service from its program's + * commander action, and ordinary rows * can inject that service and read it from lazily resolved config — * `port: !!js ctx.webStartup.port ?? 3080` — so a flag beats the value written * beside it. No row has launcher-level command-line status. @@ -76,35 +77,25 @@ export const internals: { stdout: { write(chunk: string): unknown }; stderr: { w stderr: process.stderr, } -/** - * Resolve parsed arguments into an app-owned value. Call - * `program.error(...)` to reject the invocation with a usage message instead - * of throwing. - * @param program - the parsed commander program. - * @param ctx - the plugin context that received the command line. - * @returns the value an ordinary provider plugin may publish. - */ -export type CmdlinePlan = (program: Command, ctx: Context) => T - /** * Parse the launcher's immutable argument snapshot with an app's commander - * program. The caller decides whether and how to publish the returned value; - * this helper has no Loader-row or service ownership semantics. + * program. Commander runs the program's own synchronous action handler on a + * successful parse; app code there publishes its service and rejects an + * invalid invocation with `program.error(...)`. This helper has no Loader-row + * or service ownership semantics. * - * Help, version, and rejected arguments are terminal for the process: commander - * writes the text, the helper requests `ctx.appExit`, and it returns - * `undefined` so the caller publishes nothing. + * Help, version, and rejected arguments — from the grammar or from an action + * — are terminal for the process: commander writes the text and the helper + * requests `ctx.appExit`. The action never runs on help, version, or a + * grammar rejection; an action must reject before it publishes, because + * statements before its `program.error(...)` have already run. * @param ctx - plugin context carrying `cmdlineArgs` and `appExit`. - * @param program - the app's commander program, with its flags and description already declared. - * @param plan - this invocation's resolved value; omitted returns an empty object. - * @returns the resolved value, or `undefined` when the app asked to exit. - * @throws when the launcher did not provide the command line and exit request. + * @param program - the app's commander program, with its flags, description, + * actions, and any subcommands already declared. + * @throws when the launcher did not provide the command line and exit request, + * or when no command in the program declares an action. */ -export function parseCmdline( - ctx: Context, - program: Command, - plan: CmdlinePlan = (() => ({}) as T), -): T | undefined { +export function parseCmdline(ctx: Context, program: Command): void { // Read through the global service store, not the property proxy: appExit is // an optional host value and the plugin only needs to inject cmdlineArgs. const args = ctx.get('cmdlineArgs') @@ -112,23 +103,54 @@ export function parseCmdline( if (args === undefined || exit === undefined) { throw new Error(`${program.name()}: the launcher must provide ctx.cmdlineArgs and ctx.appExit before the tree mounts`) } - program + if (!hasAction(program)) { + throw new Error(`${program.name()}: no command in the program declares an action; parseCmdline runs the invoked command's action on a successful parse, and app code there publishes its service`) + } + configureExitAndOutput(program) + try { + program.parse(args.get(), { from: 'user' }) + } catch (error) { + // exitOverride turns help, version, a parse error, and the action's own + // program.error() into a CommanderError; commander has already written the + // text through the output configured above. + if (!isCommanderError(error)) throw error + exit(error.exitCode) + } +} + +/** + * Whether any command in the tree declares an action handler. + * + * The `Command` type cannot express the action precondition, so the handler is + * read structurally (as {@link isCommanderError} reads commander's control-flow + * errors): without this guard, a program that forgot its action would parse + * successfully, publish nothing, and surface only as dependent rows pending on + * the absent service. + * @param command - the command whose tree is inspected. + * @returns true when the command or any registered subcommand has an action. + */ +function hasAction(command: Command): boolean { + if (typeof (command as unknown as { _actionHandler?: unknown })._actionHandler === 'function') return true + return command.commands.some(hasAction) +} + +/** + * Route every command's exit and output through the launcher adapter. + * + * Commander copies `exitOverride` and output configuration into a subcommand + * only at registration, so a root-only override would let an + * already-registered subcommand's rejection write to the process streams and + * call `process.exit` directly, bypassing `ctx.appExit`. + * @param command - the root of the command tree to configure. + */ +function configureExitAndOutput(command: Command): void { + command .exitOverride() .configureOutput({ writeOut: text => void internals.stdout.write(text), writeErr: text => void internals.stderr.write(text), }) - try { - program.parse(args.get(), { from: 'user' }) - return plan(program, ctx) - } catch (error) { - // exitOverride turns help, version, a parse error, and a plan's own - // program.error() into a CommanderError; commander has already written the - // text through the output configured above. - if (!isCommanderError(error)) throw error - exit(error.exitCode) - return undefined - } + for (const child of command.commands) configureExitAndOutput(child) } /** diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts index 941bfe727e..d05126a29f 100644 --- a/packages/boot/cmdline/tests/cmdline.spec.ts +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -14,7 +14,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { afterEach, describe, expect, it } from 'vitest' -import { internals, parseCmdline, provideCmdline, type CmdlinePlan } from '../src/index.ts' +import { internals, parseCmdline, provideCmdline } from '../src/index.ts' /** Every value one boot of the fixture tree observed. */ interface Observed { @@ -43,8 +43,8 @@ function demoCommand(): Command { return new Command().name('demo').exitOverride().option('--port ', 'listen port') } -/** The fixture app's plan: the resolved values its rows read. */ -const demoPlan: CmdlinePlan<{ port?: number }> = (program) => { +/** The fixture app's action body: the resolved values its rows read. */ +const resolveDemo = (program: Command): { port?: number } => { const port = program.opts<{ port?: string }>().port if (port === undefined) return {} if (!/^\d+$/.test(port)) program.error(`error: --port must be a number, got ${JSON.stringify(port)}`) @@ -58,12 +58,12 @@ const expression = (source: string): unknown => ({ __jsExpr: source }) * Mount a two-row composition the way a profile boot does: both rows at once, * with Loader ordering config resolution from their injections. * @param args - the invocation's inner arguments. - * @param plan - the app's plan; defaults to the fixture's own. + * @param resolve - the app's action body; defaults to the fixture's own. * @returns the booted fixture. */ async function bootFixture( args: string[], - plan: CmdlinePlan = demoPlan, + resolve: (program: Command) => unknown = resolveDemo, options: { objectInject?: boolean; withoutProvider?: boolean } = {}, ): Promise { const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-')) @@ -88,8 +88,9 @@ export function apply(ctx) { return globalThis.__provideDemoArgs(ctx) } const globals = globalThis as unknown as { __observed: Observed; __provideDemoArgs: (ctx: Context) => void } globals.__observed = observed globals.__provideDemoArgs = (ctx: Context) => { - const values = parseCmdline(ctx, demoCommand(), plan) - if (values !== undefined) ctx.provide('demoStartup', values) + const program = demoCommand() + program.action(() => { ctx.provide('demoStartup', resolve(program)) }) + parseCmdline(ctx, program) } // The composition, exactly as a profile delivers one: include patches whose @@ -133,7 +134,7 @@ describe('parseCmdline', () => { }) it('recognizes the Loader object form of a provider-service injection', async () => { - const { observed } = await bootFixture(['--port', '8080'], demoPlan, { objectInject: true }) + const { observed } = await bootFixture(['--port', '8080'], resolveDemo, { objectInject: true }) expect(observed.started).toEqual({ port: 8080 }) }) @@ -144,31 +145,35 @@ describe('parseCmdline', () => { expect(observed.exits).toEqual([0]) }) - it('rejects the invocation from the plan without starting the app', async () => { + it('rejects the invocation from the action without starting the app', async () => { const { observed } = await bootFixture(['--port', 'abc']) expect(observed.out).toContain('--port must be a number') expect(observed.started).toBeUndefined() expect(observed.exits).toEqual([1]) }) - it('rethrows a plan failure that is not commander asking to exit', async () => { - const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true }) - const plan: CmdlinePlan = () => { throw new Error('plan exploded') } - expect(() => { parseCmdline(ctx, demoCommand(), plan) }).toThrow('plan exploded') + it('rethrows an action failure that is not commander asking to exit', async () => { + const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true }) + const program = demoCommand().action(() => { throw new Error('action exploded') }) + expect(() => { parseCmdline(ctx, program) }).toThrow('action exploded') }) it('rethrows a thrown value that is not an object at all', async () => { - const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true }) - const plan: CmdlinePlan = () => { - const thrown: unknown = 'plan threw a string' + const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true }) + const program = demoCommand().action(() => { + const thrown: unknown = 'action threw a string' throw thrown - } - expect(() => { parseCmdline(ctx, demoCommand(), plan) }).toThrow('plan threw a string') + }) + expect(() => { parseCmdline(ctx, program) }).toThrow('action threw a string') }) - it('returns values without inspecting Loader rows or owning a service', async () => { - const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true }) - expect(parseCmdline(ctx, demoCommand())).toEqual({}) + it('runs the action without inspecting Loader rows or owning a service', async () => { + const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true }) + let values: unknown + const program = demoCommand() + program.action(() => { values = resolveDemo(program) }) + parseCmdline(ctx, program) + expect(values).toEqual({}) expect(ctx.get('demoStartup')).toBeUndefined() }) }) @@ -182,6 +187,28 @@ describe('provideCmdline', () => { expect(ctx.cmdlineArgs?.get()).toEqual(['--resume', 'abc']) }) + it('refuses at load a program in which no command declares an action', async () => { + const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true }) + expect(() => { parseCmdline(ctx, demoCommand()) }) + .toThrow('no command in the program declares an action') + }) + + it('routes a pre-registered subcommand rejection through the launcher exit request', () => { + const ctx = new Context() + const exits: number[] = [] + let err = '' + internals.stderr = { write: (chunk: string) => { err += chunk; return true } } + provideCmdline(ctx, { args: ['serve'], exit: code => void exits.push(code) }) + // The root declares no action of its own: the tree-wide guard accepts the + // subcommand's, and the subcommand inherits the exit and output routing. + const program = new Command().name('demo') + const child = program.command('serve') + child.action(() => { child.error('error: serve rejected') }) + parseCmdline(ctx, program) + expect(err).toContain('serve rejected') + expect(exits).toEqual([1]) + }) + it('fails loud when a parser runs without the launcher values', () => { const ctx = new Context() expect(() => { parseCmdline(ctx, demoCommand()) }) @@ -191,8 +218,15 @@ describe('provideCmdline', () => { it('lets multiple parsers read the same immutable snapshot', () => { const ctx = new Context() provideCmdline(ctx, { args: ['--port', '8080'], exit: () => {} }) - expect(parseCmdline(ctx, demoCommand(), demoPlan)).toEqual({ port: 8080 }) - expect(parseCmdline(ctx, demoCommand(), demoPlan)).toEqual({ port: 8080 }) + const parseOnce = (): unknown => { + let values: unknown + const program = demoCommand() + program.action(() => { values = resolveDemo(program) }) + parseCmdline(ctx, program) + return values + } + expect(parseOnce()).toEqual({ port: 8080 }) + expect(parseOnce()).toEqual({ port: 8080 }) expect(Object.isFrozen(ctx.cmdlineArgs?.get())).toBe(true) }) }) diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts index bfb4d44e51..cb56b5ae9a 100644 --- a/packages/bundle/headless/src/startup.ts +++ b/packages/bundle/headless/src/startup.ts @@ -41,22 +41,17 @@ Examples: } /** - * Turn the parsed command line into the runner's task. - * @param program - the parsed headless command. - * @returns the runner's service value. - */ -function planHeadlessStartup(program: Command): HeadlessStartupValues { - const task = program.args.join(' ') - if (task.trim() === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"') - return { task } -} - -/** - * Parse and provide the one-shot task as an ordinary Cordis service. + * Parse and provide the one-shot task as an ordinary Cordis service. The + * command's action publishes the task; a missing or whitespace-only task is a + * usage error, so on rejection (and on `--help`) nothing is provided. * @param ctx - plugin context carrying the command line. - * @returns nothing once the task is provided, or when the command requested exit. */ export function apply(ctx: Context): void { - const values = parseCmdline(ctx, headlessCommand(), planHeadlessStartup) - if (values !== undefined) ctx.provide(HEADLESS_STARTUP_SERVICE, values) + const program = headlessCommand() + program.action(() => { + const task = program.args.join(' ') + if (task.trim() === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"') + ctx.provide(HEADLESS_STARTUP_SERVICE, { task } satisfies HeadlessStartupValues) + }) + parseCmdline(ctx, program) } diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 6fc502a4d8..c696b41503 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -377,12 +377,15 @@ disabled: true # The preset roster. `config/agent-presets/` ships with the deployment and is -# read-only (its entries carry `system` trust); -# `$DSH_HOME/.agent-presets` is where a person — or an agent — authors their own, and -# carries the same trust as shell access because a preset IS a composition. -# `roots` is an assembly fact, not user config: the shipped preset directory -# ships beside this file, so AppCLIEntry resolves it and patches it in — the -# same treatment `distIndex` gets on the webserver row. +# read-only (its entries carry `system` trust); `$DSH_HOME/.agent-presets` is +# where a person — or an agent — authors their own, and carries the same trust +# as shell access because a preset IS a composition. +# +# Only the SHIPPED root is an assembly fact: it sits beside the installed app's +# own config, so `apps/cli`'s `composeProfile` resolves and patches it in — the +# same treatment `distIndex` gets on the webserver row. The writable root is +# `dsh-agent-presets`' own default (`includeUserRoot`), so a composition that +# never reaches that patch still finds a person's presets. - insert: - id: agent-presets name: '@deepseek-ai/dsh-agent-presets' diff --git a/packages/bundle/web-app/src/startup.ts b/packages/bundle/web-app/src/startup.ts index 90de34b01d..2aaf89a742 100644 --- a/packages/bundle/web-app/src/startup.ts +++ b/packages/bundle/web-app/src/startup.ts @@ -57,28 +57,24 @@ Examples: } /** - * Turn the parsed flags into the value injected rows read. - * @param program - the parsed web command. - * @returns this invocation's immutable Web options. - */ -function planWebStartup(program: Command): WebStartupValues { - const options = program.opts() - if (options.port !== undefined && !/^\d+$/.test(options.port)) { - program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) - } - return { - ...options.host !== undefined && { host: options.host }, - ...options.port !== undefined && { port: Number(options.port) }, - trustedHosts: options.trustedHost ?? [], - } -} - -/** - * Parse and provide the Web invocation as an ordinary Cordis service. + * Parse and provide the Web invocation as an ordinary Cordis service. The + * command's action publishes the flags this invocation named; a non-numeric + * `--port` is a usage error, so on rejection (and on `--help`) nothing is + * provided. * @param ctx - plugin context carrying the command line. - * @returns nothing once values are provided, or when the command requested exit. */ export function apply(ctx: Context): void { - const values = parseCmdline(ctx, webCommand(), planWebStartup) - if (values !== undefined) ctx.provide(WEB_STARTUP_SERVICE, values) + const program = webCommand() + program.action(() => { + const options = program.opts() + if (options.port !== undefined && !/^\d+$/.test(options.port)) { + program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) + } + ctx.provide(WEB_STARTUP_SERVICE, { + ...options.host !== undefined && { host: options.host }, + ...options.port !== undefined && { port: Number(options.port) }, + trustedHosts: options.trustedHost ?? [], + } satisfies WebStartupValues) + }) + parseCmdline(ctx, program) } diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 46a362a150..8085c7d323 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2513,6 +2513,38 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { emitHost({ type: 'host/workspace-removed', workspaceId }) return ok(request, { deleted: true as const }) }, + insertBefore: (request) => { + const { workspaceId, beforeWorkspaceId } = request.payload + const source = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId) + const anchor = beforeWorkspaceId === undefined + ? workspaces.length + : workspaces.findIndex(workspace => workspace.workspaceId === beforeWorkspaceId) + const missing = source === -1 ? workspaceId : anchor === -1 ? beforeWorkspaceId : undefined + if (missing !== undefined) { + return err(request, { + code: 'workspace-not-found', + message: `no workspace ${missing}`, + details: { workspaceId: missing }, + }) + } + if (beforeWorkspaceId !== workspaceId) { + const previousOrder = workspaces.map(candidate => candidate.workspaceId) + const [workspace] = workspaces.splice(source, 1) + /* v8 ignore next -- source was resolved from the same array immediately above. */ + if (workspace === undefined) throw new Error(`fixture lost workspace ${workspaceId}`) + const at = beforeWorkspaceId === undefined + ? workspaces.length + : workspaces.findIndex(candidate => candidate.workspaceId === beforeWorkspaceId) + workspaces.splice(at, 0, workspace) + if (workspaces.some((candidate, index) => candidate.workspaceId !== previousOrder[index])) { + emitHost({ + type: 'host/workspace-order-changed', + workspaceIds: workspaces.map(candidate => candidate.workspaceId), + }) + } + } + return ok(request, { workspaceIds: workspaces.map(candidate => candidate.workspaceId) }) + }, insertSessionBefore: (request) => { const { workspaceId, sessionId, beforeSessionId } = request.payload const workspace = workspaces.find(w => w.workspaceId === workspaceId) @@ -2959,6 +2991,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'workspace.create': return this.api.workspace.create(request) case 'workspace.rename': return this.api.workspace.rename(request) case 'workspace.delete': return this.api.workspace.delete(request) + case 'workspace.insertBefore': return this.api.workspace.insertBefore(request) case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request) case 'workspace.archiveSession': return this.api.workspace.archiveSession(request) case 'skill.list': return this.api.skills.list(request) diff --git a/packages/client/connection/tests/fake-api.client.ts b/packages/client/connection/tests/fake-api.client.ts index d2cec5ed54..bee4fc0ce0 100644 --- a/packages/client/connection/tests/fake-api.client.ts +++ b/packages/client/connection/tests/fake-api.client.ts @@ -3,7 +3,7 @@ // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. import type { HostFrame, IApiClient, ModelSelection, MuxFrame, - RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry, + RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry, WorkspaceId, } from '../src/client/api.ts' import { RpcId } from '../src/client/api.ts' @@ -158,6 +158,9 @@ export class FakeApiClient implements IApiClient { workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, }))), delete: (payload: unknown) => this.record('workspace.delete', payload, Promise.resolve(ok({ deleted: true as const }))), + insertBefore: (payload: unknown) => this.record('workspace.insertBefore', payload, Promise.resolve(ok({ + workspaceIds: [(payload as { workspaceId: WorkspaceId }).workspaceId], + }))), insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({ workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, }))), diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts index b7d4d12e8d..109e7acd93 100644 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ b/packages/client/connection/tests/fixture.client.spec.ts @@ -264,7 +264,12 @@ describe('createFixtureApi', () => { await consuming if (!created.result.ok) throw new Error('create failed') const createdId = created.result.value.sessionId - expect(seen).toEqual([{ type: 'host/session-added', sessionId: createdId, blank: true, cwd: '/tmp/fixture' }]) + expect(seen).toHaveLength(1) + const added = seen[0] + if (added?.type !== 'host/session-added') throw new Error('session-added frame missing') + expect(added).toEqual({ + type: 'host/session-added', sessionId: createdId, blank: true, cwd: '/tmp/fixture', + }) const list = await api.sessions.list(req({})) if (!list.result.ok) throw new Error('list failed') expect(list.result.value.items.some(s => s.sessionId === createdId)).toBe(true) @@ -699,7 +704,11 @@ describe('createFixtureApi', () => { await consuming // The session lands with the workspace's path as cwd, and the account // write pushes the fresh workspace snapshot after session-added. - expect(seen[0]).toEqual({ type: 'host/session-added', sessionId: id, blank: true, cwd: '/tmp/fixture' }) + const added = seen[0] + if (added?.type !== 'host/session-added') throw new Error('session-added frame missing') + expect(added).toEqual({ + type: 'host/session-added', sessionId: id, blank: true, cwd: '/tmp/fixture', + }) expect(seen[1]).toMatchObject({ type: 'host/workspace-changed', workspace: { workspaceId: 'fx-ws-fixture', sessionIds: [id, 'fx-alpha', 'fx-beta', 'fx-gamma'] }, @@ -728,7 +737,12 @@ describe('createFixtureApi', () => { expect(frames[0]).toMatchObject({ type: 'host/workspace-changed', workspace: { sessionIds: [preallocated] }, }) - expect(frames[1]).toEqual({ type: 'host/session-added', sessionId: preallocated, blank: true, cwd: made.result.value.workspace.path }) + const added = frames[1] + if (added?.type !== 'host/session-added') throw new Error('session-added frame missing') + expect(added).toEqual({ + type: 'host/session-added', sessionId: preallocated, blank: true, + cwd: made.result.value.workspace.path, + }) const retried = await api.sessions.create(req({ workspaceId: made.result.value.workspace.workspaceId, diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 1540cdc228..bd5ce42dfe 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -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: f4823f58ec79df0cbccfff0a08d9bb59b9a3ac8d -README.zh.md: ce8117fc4c95071a6db8592302030a8a63b5478b +README.md: 44fd9b84e45c0a4d7f5846ce9ba040ef41b8b446 +README.zh.md: 7c5a70ef5d032fab8d3b75e84de6608b43f2e294 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index f4823f58ec..44fd9b84e4 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -16,7 +16,7 @@ The callback returns one synchronous disposer or an iterable of disposers. A gen ## Workspace and Session lists -Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal frames and unary mutation echoes arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them; reconnect still takes `workspace.list` as the baseline. Workspace recency is derived only after both baselines are ready and never changes Workspace list order. +Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal/order frames and unary mutation echoes arriving during a list request replay over its response. Every successful Workspace baseline re-establishes Host-durable Workspace order so reconnects adopt changes committed while this client was offline. `WorkspacesService.insertBefore` installs an optimistic order immediately; only the latest unary echo may replace it, a newer Host order frame outranks an older echo, and a latest rejected request restores the last Host-confirmed order rather than an earlier uncommitted drag. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them. Workspace recency is derived only after both baselines are ready and never changes Workspace list order. `SessionSummary.pendingInteraction` classifies the live user action blocking a Session as `approval`, `plan-review`, or `question`. `SessionManager` tracks answerable requested/resolved mux frames by their stable request identities even before a Session object is instantiated; pre-instantiation buffering retains every live request, replaces replay duplicates, and removes resolved requests so the list status always has a matching answerable `PendingWait` when the Session is opened. The first pending question takes presentation priority over concurrent approvals to match composer routing, while only a request that satisfies the plan-review composer's binary rendering constraints keeps the distinct `plan-review` status. The state is connection-generation scoped: disconnect clears it, and mux-open replay restores only requests that remain pending. @@ -34,7 +34,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and ## New Session and the blank mirror -`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure. +`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `session.create({workspaceId})`, returning the session id for the caller to open. The shared `startSession` action targets an explicit Workspace first, then the current Session's Workspace, then the derived recent Workspace; with no Workspace it clears into the blank New Session page. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure. `Session.composerPhase` treats any visible non-command Chat Node as conversation content, so a client plugin can project durable human input without opening a turn while a window containing only generic command rows retains the Host blank posture. List hiding and blank-session reuse still follow the Host blank bit. A history window that lacks the plugin-owned input Node returns to that blank posture until an older page restores it. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index ce8117fc4c..7c5a70ef5d 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -16,7 +16,7 @@ ## Workspace 与 Session 列表 -Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。 +Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除/顺序帧与一元变更回显会在其响应之上回放。每次成功的 Workspace 基线都会重新建立 Host 持久 Workspace 顺序,因此重连会接纳该客户端离线期间提交的变更。`WorkspacesService.insertBefore` 会立即安装乐观顺序;只有最新一元回声可以替换它,更新的 Host 顺序帧优先于旧回声,而最新请求被拒时会恢复最近一次由 Host 确认的顺序,不会恢复更早且尚未提交的拖拽。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。 `SessionSummary.pendingInteraction` 将阻塞 Session 的实时用户操作分类为 `approval`、`plan-review` 或 `question`。`SessionManager` 依据稳定的请求标识跟踪可应答请求的 requested/resolved mux 帧,即使 `Session` 对象尚未实例化也不例外;实例化前的缓冲会保留每个仍有效的请求,替换回放产生的重复项,并移除已解决的请求,因此打开 Session 时,列表状态始终有一个对应的可应答 `PendingWait`。审批与问题并发时,第一个 pending 问题具有更高的呈现优先级,以匹配 composer 路由;只有满足 plan-review composer 二元呈现约束的请求才会保留独立的 `plan-review` 状态。该状态的作用域限定在连接代次内:断连时清除,mux 打开时的回放只恢复仍处于 pending 的请求。 @@ -34,7 +34,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## New Session 与 blank 镜像 -`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd,避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。 +`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd,避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。共享的 `startSession` 操作优先使用明确指定的 Workspace,其次使用当前 Session 所属 Workspace,再其次使用派生的最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。 `Session.composerPhase` 把任何可见的非命令 Chat Node 视为对话内容,因此客户端插件可以在不打开轮次的情况下投影持久用户输入,而仅包含通用命令行的窗口仍保持 Host blank 状态。列表隐藏和空白会话复用仍遵循 Host blank 位。缺少插件输入 Node 的历史窗口会恢复该空白状态,直到加载更早页面后该 Node 恢复。 diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index 8441de8eb0..4012086c3d 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -21,9 +21,11 @@ export interface IWorkspaces { */ connectWorkspace(workspaceId: WorkspaceId): Promise /** - * The New Session flow: connect the target (or recent) Workspace and open - * the resulting session; failures surface on the session list state. - * @param workspaceId - explicit target; omitted uses the recency projection. + * The New Session flow: connect the explicit, current-Session, or recent + * Workspace and open the resulting session; failures surface on the session + * list state. + * @param workspaceId - explicit target; omitted inherits the current + * Session's Workspace before falling back to the recency projection. */ startSession(workspaceId?: WorkspaceId): void /** @@ -68,6 +70,12 @@ export interface IWorkspaces { * @param workspaceId - target workspace. */ delete(workspaceId: WorkspaceId): Promise + /** + * Move a Workspace within the registry display order. + * @param workspaceId - Workspace to move. + * @param beforeWorkspaceId - Anchor workspace; omitted appends. + */ + insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise /** * Move an accounted session within/into a Workspace's ordered list. * @param workspaceId - target workspace. diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 3cc46843fc..bdf5c5a187 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -72,6 +72,7 @@ type SessionListMutation = | { kind: 'upsert'; summary: SessionSummary } | { kind: 'remove'; sessionId: SessionId } | { kind: 'status'; sessionId: SessionId; running: boolean } + | { kind: 'activity'; sessionId: SessionId; updatedAt: number } /** Local first-send flip: the sender clears blank without waiting for a host frame. */ | { kind: 'engaged'; sessionId: SessionId } @@ -682,6 +683,16 @@ export class SessionManager { handleMuxEnvelope(envelope: RpcRequest): void { const frame = envelope.payload if (frame.type === 'stream/error') return // Controller already treats this as stream failure + if ( + frame.type === 'session/event' + && frame.event.type === 'user/message' + && frame.event.data.source.kind === 'user' + ) { + // session.list supplies the cold baseline, while a direct prompt or an + // admitted steer advances it between pulls. Max keeps replayed or + // repaired older user messages from moving the row backwards. + this.recordMutation({ kind: 'activity', sessionId: frame.sessionId, updatedAt: frame.event.time }) + } if (frame.type === 'session/projection') { // Finished host-computed value: land it in the resident store whether or // not the Session is instantiated (list rows read the 'title' key). The @@ -1101,6 +1112,11 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi && (summary.running !== mutation.running || (mutation.running && summary.blank)) ? { ...summary, running: mutation.running, blank: summary.blank && !mutation.running } : summary) + case 'activity': + return summaries.map(summary => summary.sessionId === mutation.sessionId + && mutation.updatedAt > summary.updatedAt + ? { ...summary, updatedAt: mutation.updatedAt } + : summary) case 'engaged': return summaries.map(summary => summary.sessionId === mutation.sessionId && summary.blank ? { ...summary, blank: false } diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index dc618977d8..9b54dbb429 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -4,7 +4,6 @@ import type { HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, SessionId, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-api-remotes/client' import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' -import { mergeOrderedBaseline } from '../ordered-baseline.ts' import { Notifier } from '../sessions/notifier.ts' import { Workspace, type WorkspaceCreateInput } from './workspace.ts' @@ -30,6 +29,7 @@ export interface WorkspaceListSnapshot { type WorkspaceDelta = | { type: 'upsert'; workspace: WorkspaceView } | { type: 'remove'; workspaceId: WorkspaceId } + | { type: 'order'; workspaceIds: readonly WorkspaceId[] } /** Workspace object cluster driven by one list baseline and changed-frame upserts. */ export class WorkspaceManager { @@ -51,6 +51,12 @@ export class WorkspaceManager { * mirror of replaying refreshFrames over the item baseline. */ private archivedSupersedesRefresh = false + /** Latest local reorder request; only its unary echo may install order. */ + private orderRequestGeneration = 0 + /** Increments on order frames so a later remote commit outranks an older unary echo. */ + private orderFrameGeneration = 0 + /** Last complete order accepted from a Host baseline, frame, or current unary echo. */ + private committedOrder: WorkspaceId[] = [] /** * Ids this process has seen removed, kept for the connection's lifetime so * a late changed frame or a stale baseline row cannot resurrect a deleted @@ -72,16 +78,15 @@ export class WorkspaceManager { /** * Refresh from workspace.list. The first successful response establishes - * Host order; later responses update membership and values without moving - * identities already visible to the client. Frames arriving during the RPC - * are replayed over its response. + * Host order; later responses re-establish the durable order so reconnects + * adopt reorders committed while this client was offline. Frames arriving + * during the RPC are replayed over its response. * @returns the shared in-flight refresh. */ refresh(): Promise { if (this.inflight !== null) return this.inflight this.state = 'loading' this.error = null - const established = this.itemViews() const frames: WorkspaceDelta[] = [] this.refreshFrames = frames this.notifier.markDirty() @@ -89,9 +94,7 @@ export class WorkspaceManager { try { const { result } = await this.api.workspace.list({}) if (result.ok) { - let items = this.phase === 'pending' - ? result.value.items - : mergeOrderedBaseline(established, result.value.items, workspace => workspace.workspaceId) + let items = result.value.items items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId)) for (const delta of frames) items = applyWorkspaceDelta(items, delta) this.installViews(items) @@ -157,6 +160,44 @@ export class WorkspaceManager { return result } + /** + * Move a Workspace within the registry display order and install the full + * returned order without waiting for the Host frame. + * @param workspaceId - Workspace to move. + * @param beforeWorkspaceId - Anchor workspace; omitted appends. + * @returns the wire result. + */ + async insertBefore( + workspaceId: WorkspaceId, + beforeWorkspaceId?: WorkspaceId, + ): Promise> { + const requestGeneration = ++this.orderRequestGeneration + const frameGeneration = this.orderFrameGeneration + const localOrder = this.itemViews().map(workspace => workspace.workspaceId) + this.installOrder(insertIdBefore(localOrder, workspaceId, beforeWorkspaceId)) + let result: RpcResult<{ workspaceIds: WorkspaceId[] }> + try { + ;({ result } = await this.api.workspace.insertBefore({ + workspaceId, + ...beforeWorkspaceId === undefined ? {} : { beforeWorkspaceId }, + })) + } catch (error) { + if (requestGeneration === this.orderRequestGeneration + && frameGeneration === this.orderFrameGeneration) { + this.installOrder(this.committedOrder) + } + throw error + } + if (result.ok && requestGeneration === this.orderRequestGeneration + && frameGeneration === this.orderFrameGeneration) { + this.installOrder(result.value.workspaceIds, true) + } else if (!result.ok && requestGeneration === this.orderRequestGeneration + && frameGeneration === this.orderFrameGeneration) { + this.installOrder(this.committedOrder) + } + return result + } + /** * Move a session within its Workspace's manual order, then publish the * returned snapshot without waiting for the changed frame. @@ -198,6 +239,10 @@ export class WorkspaceManager { handleHostEnvelope(envelope: RpcRequest): void { if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace) else if (envelope.payload.type === 'host/workspace-removed') this.remove(envelope.payload.workspaceId) + else if (envelope.payload.type === 'host/workspace-order-changed') { + this.orderFrameGeneration++ + this.installOrder(envelope.payload.workspaceIds, true) + } else if (envelope.payload.type === 'host/archived-sessions-changed') { this.installArchived(envelope.payload.archivedSessionIds) } @@ -249,6 +294,24 @@ export class WorkspaceManager { this.notifier.markDirty() } + /** Reorder known Workspace objects, optionally recording a Host-committed sequence. */ + private installOrder(workspaceIds: readonly WorkspaceId[], committed = false): void { + if (committed) { + this.refreshFrames?.push({ type: 'order', workspaceIds }) + this.committedOrder = [...workspaceIds] + } + const rank = new Map(workspaceIds.map((id, index) => [id, index])) + const items = [...this.items].sort((left, right) => { + const leftId = left.getSnapshot().view?.workspaceId + const rightId = right.getSnapshot().view?.workspaceId + return (leftId === undefined ? Number.MAX_SAFE_INTEGER : rank.get(leftId) ?? Number.MAX_SAFE_INTEGER) + - (rightId === undefined ? Number.MAX_SAFE_INTEGER : rank.get(rightId) ?? Number.MAX_SAFE_INTEGER) + }) + if (items.every((item, index) => item === this.items[index])) return + this.items = items + this.notifier.markDirty() + } + /** Upsert one Host view, optionally retaining the local object that materialized it. */ private upsert(view: WorkspaceView, identity?: Workspace): void { if (this.removedIds.has(view.workspaceId)) return @@ -259,6 +322,9 @@ export class WorkspaceManager { // late unary response cannot roll back a newer frame. const installed = index === -1 ? undefined : this.items[index]?.getSnapshot().view if (installed !== undefined && Date.parse(view.updatedAt) < Date.parse(installed.updatedAt)) return + if (!this.committedOrder.includes(view.workspaceId)) { + this.committedOrder = [view.workspaceId, ...this.committedOrder] + } if (identity !== undefined) { this.items = index === -1 ? [identity, ...this.items] @@ -276,6 +342,7 @@ export class WorkspaceManager { private remove(workspaceId: WorkspaceId, direct = false): void { this.refreshFrames?.push({ type: 'remove', workspaceId }) this.removedIds.add(workspaceId) + this.committedOrder = this.committedOrder.filter(id => id !== workspaceId) const items = this.items.filter(item => item.getSnapshot().view?.workspaceId !== workspaceId) if (items.length === this.items.length) { @@ -309,6 +376,7 @@ export class WorkspaceManager { installed.set(view.workspaceId, workspace) } this.items = [...installed.values()] + this.committedOrder = views.map(view => view.workspaceId) } private itemViews(): readonly WorkspaceView[] { @@ -332,7 +400,26 @@ function upsertWorkspace(items: readonly WorkspaceView[], workspace: WorkspaceVi /** Replay one ordered delta over a baseline: upsert in place, or drop the removed id. */ function applyWorkspaceDelta(items: readonly WorkspaceView[], delta: WorkspaceDelta): WorkspaceView[] { - return delta.type === 'upsert' - ? upsertWorkspace(items, delta.workspace) - : items.filter(workspace => workspace.workspaceId !== delta.workspaceId) + if (delta.type === 'upsert') return upsertWorkspace(items, delta.workspace) + if (delta.type === 'remove') { + return items.filter(workspace => workspace.workspaceId !== delta.workspaceId) + } + const rank = new Map(delta.workspaceIds.map((id, index) => [id, index])) + return [...items].sort((left, right) => + (rank.get(left.workspaceId) ?? Number.MAX_SAFE_INTEGER) + - (rank.get(right.workspaceId) ?? Number.MAX_SAFE_INTEGER)) +} + +/** Move one known id before an optional anchor; unknown ids leave the order unchanged. */ +function insertIdBefore( + ids: readonly WorkspaceId[], + id: WorkspaceId, + beforeId?: WorkspaceId, +): WorkspaceId[] { + if (!ids.includes(id) || (beforeId !== undefined && !ids.includes(beforeId)) || beforeId === id) { + return [...ids] + } + const without = ids.filter(candidate => candidate !== id) + const at = beforeId === undefined ? without.length : without.indexOf(beforeId) + return [...without.slice(0, at), id, ...without.slice(at)] } diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 733a894c7b..a73dad2430 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -167,14 +167,20 @@ export class WorkspacesService implements IWorkspaces { /** * The shared New Session action behind the shell entry points (sidebar * button, workspace browser): resolve the target Workspace — explicit wins, - * else the recent-Workspace projection — connect its blank session and - * navigate there; with no Workspace at all, clear the selection into the - * New Session view state. Connect failures are non-fatal (console - * diagnostics; the current view stays usable). + * then the current Session's Workspace, then the recent-Workspace + * projection — connect its blank session and navigate there; with no + * Workspace at all, clear the selection into the New Session view state. + * Connect failures are non-fatal (console diagnostics; the current view + * stays usable). * @param workspaceId - explicit target Workspace for scoped actions. */ startSession(workspaceId?: WorkspaceId): void { - const target = workspaceId ?? this.list.getSnapshot().recentWorkspaceId + const workspace = this.list.getSnapshot() + const current = this.sessions.list.getSnapshot().current + const currentWorkspaceId = current === undefined + ? undefined + : workspace.items.find(item => item.sessionIds.includes(current))?.workspaceId + const target = workspaceId ?? currentWorkspaceId ?? workspace.recentWorkspaceId if (target === undefined) { this.sessions.clear() return @@ -265,6 +271,16 @@ export class WorkspacesService implements IWorkspaces { if (!result.ok) throw new Error(`workspace delete failed: ${result.error.code}: ${result.error.message}`) } + /** + * Move a Workspace within the durable registry display order. + * @param workspaceId - Workspace to move. + * @param beforeWorkspaceId - Anchor workspace; omitted appends. + */ + async insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise { + const result = await this.manager.insertBefore(workspaceId, beforeWorkspaceId) + if (!result.ok) throw new Error(`workspace reorder failed: ${result.error.code}: ${result.error.message}`) + } + /** * Archive a session into the registry-global set. Clearing an archived * current selection is the projection sweep's job (one rule for the local diff --git a/packages/client/runtime/tests/fake-api.client.ts b/packages/client/runtime/tests/fake-api.client.ts index 94766c09e2..33a0efbbfd 100644 --- a/packages/client/runtime/tests/fake-api.client.ts +++ b/packages/client/runtime/tests/fake-api.client.ts @@ -195,6 +195,9 @@ export class FakeApiClient implements IApiClient { onWorkspaceDelete: (payload: unknown) => Promise> = () => Promise.resolve(ok({ deleted: true })) + onWorkspaceInsertBefore: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ workspaceIds: [] })) + onWorkspaceInsertSessionBefore: (payload: unknown) => Promise> = () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) @@ -210,6 +213,8 @@ export class FakeApiClient implements IApiClient { create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)), rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)), delete: (payload: unknown) => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)), + insertBefore: (payload: unknown) => + this.record('workspace.insertBefore', payload, this.onWorkspaceInsertBefore(payload)), insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)), archiveSession: (payload: unknown) => diff --git a/packages/client/runtime/tests/manager.client.spec.ts b/packages/client/runtime/tests/manager.client.spec.ts index 02e00b0326..01bad9cc55 100644 --- a/packages/client/runtime/tests/manager.client.spec.ts +++ b/packages/client/runtime/tests/manager.client.spec.ts @@ -7,7 +7,7 @@ import { describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { SessionManager } from '../src/client/sessions/manager.ts' import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' -import { entries, plainTurn } from './event-script.client.ts' +import { entries, ev, plainTurn } from './event-script.client.ts' const S1 = 'fk-m1' as SessionId const S2 = 'fk-m2' as SessionId @@ -113,6 +113,46 @@ describe('list lifecycle', () => { expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1]) }) + it('advances list activity only for direct user messages', async () => { + const api = new FakeApiClient() + api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] })) + const manager = new SessionManager(api, fakeRemote()) + await manager.refreshList() + + // Both a new prompt and an admitted steer land as a user-sourced message. + const activity = { ...ev.user(10, 'new'), time: 500 } + manager.handleMuxEnvelope({ + rpcId: 'activity' as never, + payload: { type: 'session/event', sessionId: S1, event: activity }, + }) + expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(500) + + manager.handleMuxEnvelope({ + rpcId: 'older' as never, + payload: { type: 'session/event', sessionId: S1, event: { ...activity, time: 400 } }, + }) + manager.handleMuxEnvelope({ + rpcId: 'assistant' as never, + payload: { type: 'session/event', sessionId: S1, event: { ...ev.assistant(11, 0, 'reply'), time: 600 } }, + }) + + const injected = ev.user(12, 'context') + if (injected.type !== 'user/message') throw new Error('user builder returned another event type') + manager.handleMuxEnvelope({ + rpcId: 'injected' as never, + payload: { + type: 'session/event', + sessionId: S1, + event: { + ...injected, + time: 700, + data: { ...injected.data, source: { kind: 'plugin', plugin: 'test' } }, + }, + }, + }) + expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(500) + }) + it('keeps the error in the list snapshot on failure', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} })) diff --git a/packages/client/runtime/tests/workspaces-service.client.spec.ts b/packages/client/runtime/tests/workspaces-service.client.spec.ts index 02df6e60ad..8cc298818b 100644 --- a/packages/client/runtime/tests/workspaces-service.client.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.client.spec.ts @@ -1,5 +1,5 @@ import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-remotes/client' import { SessionsService } from '../src/client/sessions/service.ts' import { WorkspaceManager } from '../src/client/workspaces/manager.ts' @@ -17,7 +17,7 @@ function workspace(id: string, sessionIds: SessionId[] = [], createdAt = '2026-0 } describe('WorkspaceManager', () => { - it('replays changed frames over hydration and keeps established order on refresh', async () => { + it('replays changed frames over hydration and adopts the durable order on refresh', async () => { const api = new FakeApiClient() const gate = deferred>>() api.onWorkspaceList = () => gate.promise @@ -36,7 +36,7 @@ describe('WorkspaceManager', () => { items: [workspace('old'), workspace('new')] as never[], })) await manager.refresh() - expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['new', 'old']) + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['old', 'new']) }) it('single-flights refreshes and exposes result and transport failures independently of readiness', async () => { @@ -77,6 +77,73 @@ describe('WorkspaceManager', () => { }) }) + it('reorders optimistically while newer Host frames outrank unary echoes and failures roll back', async () => { + const api = new FakeApiClient() + api.onWorkspaceList = () => Promise.resolve(ok({ + items: [workspace('one'), workspace('two'), workspace('three')] as never[], + })) + const manager = new WorkspaceManager(api) + await manager.refresh() + + const gate = deferred>>() + api.onWorkspaceInsertBefore = () => gate.promise + const pending = manager.insertBefore(wid('three'), wid('one')) + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two']) + manager.handleHostEnvelope({ + rpcId: 'newer-order' as never, + payload: { + type: 'host/workspace-order-changed', + workspaceIds: [wid('one'), wid('three'), wid('two')], + }, + }) + gate.resolve(ok({ workspaceIds: [wid('three'), wid('one'), wid('two')] })) + await pending + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two']) + + api.onWorkspaceInsertBefore = () => Promise.resolve(err({ + code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'three' }, + })) + const rejected = manager.insertBefore(wid('three')) + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three']) + await expect(rejected).resolves.toMatchObject({ ok: false }) + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two']) + + api.onWorkspaceInsertBefore = () => Promise.reject(new Error('transport down')) + const disconnected = manager.insertBefore(wid('three'), wid('one')) + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two']) + await expect(disconnected).rejects.toThrow('transport down') + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two']) + }) + + it('rolls overlapping rejected reorders back to the last Host-confirmed order', async () => { + const api = new FakeApiClient() + api.onWorkspaceList = () => Promise.resolve(ok({ + items: [workspace('one'), workspace('two'), workspace('three')] as never[], + })) + const manager = new WorkspaceManager(api) + await manager.refresh() + const firstGate = deferred>>() + const secondGate = deferred>>() + let request = 0 + api.onWorkspaceInsertBefore = () => request++ === 0 ? firstGate.promise : secondGate.promise + + const first = manager.insertBefore(wid('three'), wid('one')) + const second = manager.insertBefore(wid('two'), wid('three')) + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one']) + + firstGate.resolve(err({ + code: 'workspace-not-found', message: 'first rejected', details: { workspaceId: 'three' }, + })) + await expect(first).resolves.toMatchObject({ ok: false }) + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one']) + + secondGate.resolve(err({ + code: 'workspace-not-found', message: 'second rejected', details: { workspaceId: 'two' }, + })) + await expect(second).resolves.toMatchObject({ ok: false }) + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three']) + }) + it('replays removal over an in-flight baseline and ignores duplicate or late updates', async () => { const api = new FakeApiClient() const gate = deferred>>() @@ -309,6 +376,72 @@ describe('WorkspacesService', () => { await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/) }) + it('moves a Workspace through the durable order RPC and surfaces Host rejection', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api, fakeRemote())) + api.onWorkspaceList = () => Promise.resolve(ok({ + items: [workspace('one'), workspace('two')] as never[], + })) + await workspaces.refresh() + api.onWorkspaceInsertBefore = () => Promise.resolve(ok({ + workspaceIds: [wid('two'), wid('one')], + })) + await expect(workspaces.insertBefore(wid('two'), wid('one'))).resolves.toBeUndefined() + expect(api.callsOf('workspace.insertBefore')).toEqual([{ + workspaceId: 'two', beforeWorkspaceId: 'one', + }]) + expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'one']) + + api.onWorkspaceInsertBefore = () => Promise.resolve(err({ + code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'ghost' }, + })) + await expect(workspaces.insertBefore(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/) + }) + + it('targets New Session at explicit, current-session, then recent Workspaces and clears with none', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const sessions = new SessionsService(ctx, api, fakeRemote()) + const workspaces = new WorkspacesService(ctx, api, sessions) + api.onWorkspaceList = () => Promise.resolve(ok({ + items: [ + workspace('current-home', [sid('current')]), + workspace('recent-home', [sid('recent')]), + ] as never[], + })) + api.onList = () => Promise.resolve(ok({ items: [ + { sessionId: sid('current'), updatedAt: 1, running: false, blank: false }, + { sessionId: sid('recent'), updatedAt: 2, running: false, blank: false }, + ] as never[] })) + await Promise.all([workspaces.refresh(), sessions.refresh()]) + await Promise.resolve() + sessions.open(sid('current')) + const unresolved = new Promise(() => {}) + const connect = vi.spyOn(workspaces, 'connectWorkspace').mockReturnValue(unresolved) + + workspaces.startSession(wid('recent-home')) + await Promise.resolve() + expect(connect).toHaveBeenLastCalledWith(wid('recent-home')) + + workspaces.startSession() + await Promise.resolve() + expect(connect).toHaveBeenLastCalledWith(wid('current-home')) + + sessions.clear() + workspaces.startSession() + await Promise.resolve() + expect(connect).toHaveBeenLastCalledWith(wid('recent-home')) + + const emptyCtx = new Context() + const emptyApi = new FakeApiClient() + const emptySessions = new SessionsService(emptyCtx, emptyApi, fakeRemote()) + const emptyWorkspaces = new WorkspacesService(emptyCtx, emptyApi, emptySessions) + const clear = vi.spyOn(emptySessions, 'clear') + emptyWorkspaces.startSession() + expect(clear).toHaveBeenCalledOnce() + }) + it('archives a session, projects the set from the response, list, and frame, and clears only the current one', async () => { const ctx = new Context() const api = new FakeApiClient() diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index 9e1061ec8c..4f6b2122cb 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -172,6 +172,16 @@ export class TestWorkspaces implements IWorkspaces { await (this.stubs.get('delete')?.(workspaceId) as Promise | undefined) } + /** + * Move a Workspace in display order (recorded; default no-op). + * @param workspaceId - Workspace to move. + * @param beforeWorkspaceId - Anchor; omitted appends. + */ + async insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise { + this.calls.push({ method: 'insertBefore', args: [workspaceId, beforeWorkspaceId] }) + await (this.stubs.get('insertBefore')?.(workspaceId, beforeWorkspaceId) as Promise | undefined) + } + /** * Move an accounted session (recorded). The default echoes a minimal view. * @param workspaceId - target workspace. diff --git a/packages/client/test-runtime/tests/runtime.client.spec.tsx b/packages/client/test-runtime/tests/runtime.client.spec.tsx index 9c012bce6d..f5e4819ea8 100644 --- a/packages/client/test-runtime/tests/runtime.client.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.client.spec.tsx @@ -578,6 +578,7 @@ describe('workspaces action face', () => { expect(renamed.title).toBe('Renamed') await ws.delete('w1' as WorkspaceId) await ws.openPath('/proj/file.ts') + await ws.insertBefore('w1' as WorkspaceId, 'w2' as WorkspaceId) const moved = await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId, 's2' as SessionId) expect(moved.sessionIds).toEqual(['s1']) // Default archive mirrors the production effect: the id joins the list @@ -585,13 +586,15 @@ describe('workspaces action face', () => { await ws.archiveSession('s1' as SessionId) expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1']) expect(ws.calls.map(c => c.method)).toEqual( - ['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertSessionBefore', 'archiveSession']) + ['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertBefore', 'insertSessionBefore', 'archiveSession']) ws.stub('create', () => Promise.resolve({ workspaceId: 'ws-x', title: 'X', path: '/x', sessionIds: [] } as never)) ws.stub('pickDirectory', () => Promise.resolve('/picked')) ws.stub('rename', () => Promise.resolve({ workspaceId: 'w1', title: 'S', path: '/s', sessionIds: [] } as never)) ws.stub('delete', () => Promise.resolve()) ws.stub('openPath', () => Promise.resolve()) + const insertBefore = vi.fn(() => Promise.resolve()) + ws.stub('insertBefore', insertBefore) ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never)) ws.stub('archiveSession', () => Promise.resolve()) expect((await ws.create({ path: '/y' })).title).toBe('X') @@ -599,6 +602,8 @@ describe('workspaces action face', () => { expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S') await ws.delete('w1' as WorkspaceId) await ws.openPath('/other') + await ws.insertBefore('w2' as WorkspaceId) + expect(insertBefore).toHaveBeenCalledWith('w2', undefined) expect((await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId)).sessionIds).toEqual([]) // The stub replaces the default set mutation: the set stays as-is. await ws.archiveSession('s2' as SessionId) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 971661cd48..473e2a1fd7 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -1,7 +1,7 @@ /* Conversation column skeleton: header (breadcrumb row only for subagents not fork + tabs) over the view area, composer InputBar at the bottom. Column width/squeeze is layout's; this fills its cell. Figma: Header 39:27730 (83px two-row), tabs 13px with - a 3px active bar. */ + a 2px active bar. */ .root { display: flex; @@ -26,9 +26,22 @@ } .header { + position: relative; flex: none; padding: 12px 28px 0 20px; - border-bottom: 1px solid var(--dsw-alias-border-l2); + border-bottom: 1px solid transparent; +} + +.header::after { + content: ''; + position: absolute; + right: 0; + bottom: 1px; + left: 0; + z-index: 0; + height: 1px; + background: var(--dsw-alias-border-l2); + pointer-events: none; } /* Blank hero/settling: keep the strict Session header mounted without taking @@ -100,13 +113,15 @@ /* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */ .tabs { + position: relative; + z-index: 1; display: flex; gap: 36px; margin-top: 4px; padding-left: 8px; } -/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500), gap 8 to the 3px bar (no bottom rounding). */ +/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500), gap 8 to the 2px bar. */ .tab { position: relative; padding: 0 0 11px; @@ -123,9 +138,10 @@ content: ''; position: absolute; right: 0; - bottom: 0; + bottom: 1px; left: 0; - height: 3px; + height: 2px; + border-radius: 2px; background: transparent; } diff --git a/packages/client/ui-layout/src/client/columns.ts b/packages/client/ui-layout/src/client/columns.ts index 51a944ef2a..374ce64703 100644 --- a/packages/client/ui-layout/src/client/columns.ts +++ b/packages/client/ui-layout/src/client/columns.ts @@ -20,10 +20,10 @@ export interface Columns { sidebar: number; center: number; details: number } /** Center column floor; only the final fallback may go below it. */ export const CENTER_MIN = 640 /** Sidebar drag clamp floor. */ -export const SIDEBAR_MIN = 280 +export const SIDEBAR_MIN = 264 /** Sidebar drag clamp ceiling. */ export const SIDEBAR_MAX = 420 -/** Sidebar width before any user drag (= the drag floor). */ +/** Sidebar width before any user drag. */ export const SIDEBAR_DEFAULT = 280 /** Closed-sidebar rail: a 24px icon column between 16px horizontal paddings. */ export const SIDEBAR_COLLAPSED = 56 diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 1a4c53cc8d..671d4a2bfd 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-models/README.md -README.md: f6604f822412e9eb4574696f5b99e73fb7bd98ff -README.zh.md: 2500bbae0982571a9a88dd5c259749e3504728de +README.md: a8d030b7676e87709fb36b87a6599decc43e0b4b +README.zh.md: 63fb1b486acc2bca34792f485ffd89fb32749e43 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index f6604f8224..a8d030b767 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -4,9 +4,9 @@ English | [中文](README.zh.md) Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status. -Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Those two are what a hand-declared route names for itself: the create card asks for both because nothing can default them, so the editor reaches both rather than leaving them to `settings.yaml`. Clearing the name unsets it and the route falls back to its id, which is what the placeholder shows; the protocol has no such fallback. A catalog route gets neither — it defaults its name from its catalog entry, and its models each carry their own protocol, so a route-level one could only override every one of them. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which would hide even the models that support the level. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped. +Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere renders as its open setup card instead of a row, but only in the first-run posture — while no provider is registered with the credential its profile names — and only until the user closes that card, after which it is an ordinary row carrying the missing-key dot. Each card kind owns its own open state, so closing one never discards a draft in another. The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Those two are what a hand-declared route names for itself: the create card asks for both because nothing can default them, so the editor reaches both rather than leaving them to `settings.yaml`. Clearing the name unsets it and the route falls back to its id, which is what the placeholder shows; the protocol has no such fallback. A catalog route gets neither — it defaults its name from its catalog entry, and its models each carry their own protocol, so a route-level one could only override every one of them. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which would hide even the models that support the level. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped. -The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. +The DeepSeek step projects first-run readiness from that same joined snapshot after earlier onboarding pages complete. The step exists to leave the user with a model to talk to, so ANY provider they can already reach ends it without rendering — a registered route whose named credential reference is stored, including a read-only launch-environment credential, or one whose profile names no reference at all and therefore authenticates natively. Only a user with none of those is asked about DeepSeek, the one route the prompt can offer a key field for. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, with the same fields the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value matching a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that pasted-line check runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. Once loaded, the page subscribes directly to forwarded `settings/document-updated`, `credentials/updated`, and `llm/adapters-updated` owner events, plus local `connection/reset`, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 2500bbae09..63fb1b486a 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -4,9 +4,9 @@ 模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。 -行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可查询提供方所提供的模型。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的那类 pi-ai 路由的**显示名称**与 **API 协议**。这两个字段是手工声明路由为自己命名的东西:创建卡片之所以索要它们,正因为没有东西能为它们兜底,因此编辑器也够得着这两个,而不是把它们留给 `settings.yaml`。清空名称即取消设置,路由退回自己的 id——占位符显示的就是它;协议没有这样的兜底。内置目录路由两个都不给:它的名称由目录条目兜底,它的每个模型各自带着自己的协议,路由级协议只可能把它们全部覆盖掉。Provider ID 保持固定:它是 settings 的键、是其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会连支持该档位的模型也一并隐藏。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`/`maxTokens`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。 +行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态——即尚无任何提供方已注册且备齐其 profile 所指名的凭据——且仅持续到用户关闭该卡片为止,此后它就是一行带缺失密钥点的普通行。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可查询提供方所提供的模型。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的那类 pi-ai 路由的**显示名称**与 **API 协议**。这两个字段是手工声明路由为自己命名的东西:创建卡片之所以索要它们,正因为没有东西能为它们兜底,因此编辑器也够得着这两个,而不是把它们留给 `settings.yaml`。清空名称即取消设置,路由退回自己的 id——占位符显示的就是它;协议没有这样的兜底。内置目录路由两个都不给:它的名称由目录条目兜底,它的每个模型各自带着自己的协议,路由级协议只可能把它们全部覆盖掉。Provider ID 保持固定:它是 settings 的键、是其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会连支持该档位的模型也一并隐藏。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`/`maxTokens`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。 -前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。凭据引用已配置时,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 +前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出首次运行就绪状态。该步骤的存在是为了让用户手上有一个可对话的模型,因此只要用户已经能触达**任何**一个提供方,它就直接完成而不渲染——已注册且其具名凭据引用已存储的路由(包括来自启动环境且只读的凭据),或 profile 根本不指名任何引用、因而走原生认证的路由。只有二者皆无的用户才会被问到 DeepSeek,即这条提示唯一能为其提供密钥输入框的路由。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它只修改自己看得见的字段,而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,使用与 pi-ai 提供方表单相同的字段。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。与整行粘贴的 `NAME=value` 环境变量匹配或首尾成对引号包裹的值,会以同一条格式失败被拒绝;这项粘贴行检查只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会直接订阅转发的 owner 事件 `settings/document-updated`、`credentials/updated`、`llm/adapters-updated`,以及本地 `connection/reset`,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 diff --git a/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx b/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx index c8668c3700..302d4592f8 100644 --- a/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx +++ b/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx @@ -1,7 +1,9 @@ /** * Official-DeepSeek first-run step. Readiness comes from the same - * provider/settings/credential join as the Models page; the prompt only - * routes the user to that page's single credential editor. + * provider/settings/credential join as the Models page: any provider the user + * can already talk to ends the step, and only a user with none is offered the + * official DeepSeek route. The prompt itself only routes to that page's single + * credential editor. */ import { useEffect, useRef } from 'react' @@ -10,7 +12,7 @@ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { BrandWordmark, Button, OnboardingSurface } from '@deepseek-ai/dsh-client-ui-primitives' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts' -import { deepSeekReadiness } from './store.ts' +import { onboardingReadiness } from './store.ts' import type { en } from './locales.ts' import styles from './DeepSeekOnboardingDialog.module.css' @@ -34,15 +36,15 @@ function assertNever(_value: never): never { } /** - * Prompt a first-run user to open Models while the official adapter exists - * and its effective credential is not configured. + * Prompt a first-run user to open Models while no provider can serve requests + * and the official adapter exists with an unconfigured effective credential. * @param props - settings-shell owner state and Models feature dependencies. * @returns the onboarding page or null when onboarding needs no intervention. */ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode { const { complete, openSection, controller, useSnapshot, t } = props const state = useSnapshot(snapshot => snapshot) - const readiness = deepSeekReadiness(state) + const readiness = onboardingReadiness(state) const titleRef = useRef(null) useEffect(() => { @@ -52,7 +54,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): useEffect(() => { if ( readiness.kind === 'adapter-absent' - || readiness.kind === 'configured' + || readiness.kind === 'provider-ready' || readiness.kind === 'unavailable' ) complete() }, [complete, readiness.kind]) @@ -72,7 +74,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): switch (readiness.kind) { case 'loading': case 'adapter-absent': - case 'configured': + case 'provider-ready': case 'unavailable': return null case 'credential-missing': diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index 1eba48903e..5fe5647b88 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -3,11 +3,13 @@ * directory, settings namespaces, and credential states, with one editor * card at a time. Rows expose only confirmed API-key state through accessible * solid configured or missing dots. A whole-section provider without a - * configured key (the unconfigured DeepSeek posture) renders as its open setup - * card instead of a row; the add flow is a card carrying the dormant-provider - * select. Every mutation writes through the wire, while a provider removal first requires - * confirmation; the page re-renders from pushed invalidations or the - * post-apply reload. + * configured key renders as its open setup card instead of a row, but only in + * the first-run posture — no provider on the page can serve requests yet — and + * only until the user closes that card; the add flow is a card carrying the + * dormant-provider select. Each card kind owns its own open state, so closing + * one never discards a draft in another. Every mutation writes through the + * wire, while a provider removal first requires confirmation; the page + * re-renders from pushed invalidations or the post-apply reload. */ import { useState } from 'react' @@ -16,7 +18,7 @@ import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' import { CustomProviderCard } from './CustomProviderCard.tsx' -import { deriveKeyRef, messageOf, protocolChoices } from './store.ts' +import { deriveKeyRef, messageOf, protocolChoices, providerUsable } from './store.ts' import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts' import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx' import type { en } from './locales.ts' @@ -116,11 +118,15 @@ export async function removeProviderProfile( /** * Whether a whole-section provider still needs its first key: an unconfigured - * credential opens the setup card instead of showing a row. + * credential opens the setup card instead of showing a row. This is the + * first-run posture alone — a user who can already reach some provider gets an + * ordinary row with the missing-key dot, since nothing here is blocking them. * @param row - the joined provider row. + * @param anyUsable - whether any joined row can already serve requests. * @returns whether to render the setup card. */ -export function needsSetup(row: ProviderRow): boolean { +export function needsSetup(row: ProviderRow, anyUsable: boolean): boolean { + if (anyUsable) return false if (row.entry.settingsPath.length > 0) return false return row.credential?.configured !== true } @@ -178,17 +184,32 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { const [deleteFailure, setDeleteFailure] = useState(undefined) const [savedTarget, setSavedTarget] = useState(undefined) const [declaring, setDeclaring] = useState(false) + const [dismissedSetup, setDismissedSetup] = useState>(() => new Set()) + + const announceSaved = (target: ProviderIdentity): void => { + // Announced only once the refreshed directory is in the snapshot the + // notice reads its name from: an apply can rename the route, and the + // target captured when the card opened still carries the old name. + void controller.load().then(() => { setSavedTarget(target) }) + } const closeEditor = (changed: boolean, target: ProviderIdentity): void => { setEditing(undefined) setAdding(false) setDeclaring(false) - if (changed) { - // Announced only once the refreshed directory is in the snapshot the - // notice reads its name from: an apply can rename the route, and the - // target captured when the card opened still carries the old name. - void controller.load().then(() => { setSavedTarget(target) }) - } + if (changed) announceSaved(target) + } + + /** + * Close a setup card, which owns none of the state above: the row-editor, + * add, and declare cards each own one of those, so clearing them here would + * discard a draft the user opened beside this card. Dismissal is this card's + * own — the provider falls back to an ordinary row for the rest of the + * session, and reopens through Edit. + */ + const closeSetup = (changed: boolean, target: ProviderIdentity): void => { + setDismissedSetup(previous => new Set([...previous, target.provider])) + if (changed) announceSaved(target) } const closeDelete = (): void => { @@ -238,6 +259,9 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { ? savedTarget : { provider: savedRow.entry.provider, displayName: savedRow.entry.displayName } + // One fact decides both first-run postures on this page and the onboarding + // step: whether the user already has a provider to talk to. + const anyUsable = state.rows.some(providerUsable) const configured = state.rows.filter(row => row.configured) const addable = state.rows.filter(row => !row.configured && row.entry.settingsNs !== '') const addTarget = adding ? editing : undefined @@ -265,9 +289,9 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { const namespace = state.namespaces.get(target.settingsNs) /* v8 ignore next -- the join marks a row configured only when its namespace resolved */ if (namespace === undefined) return null - if (needsSetup(row)) { + if (needsSetup(row, anyUsable) && !dismissedSetup.has(row.entry.provider)) { // First-run posture: the provider exists but has no key — the - // setup card IS its presence on the page. + // setup card IS its presence on the page, until the user closes it. return (
  • {renderProviderEditor({ @@ -276,7 +300,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { api, t, readOnly: !state.writable, - onClose: (changed) => { closeEditor(changed, target) }, + onClose: (changed) => { closeSetup(changed, target) }, })}
  • ) diff --git a/packages/client/ui-models/src/client/store.ts b/packages/client/ui-models/src/client/store.ts index 9cc2cb7c77..4389b9a6cb 100644 --- a/packages/client/ui-models/src/client/store.ts +++ b/packages/client/ui-models/src/client/store.ts @@ -189,32 +189,49 @@ export class ModelsSettingsStore { } } -/** DeepSeek onboarding readiness derived only from the shared Models join. */ -export type DeepSeekReadiness = +/** + * Whether a joined row can serve model requests as it stands: the route is + * registered with the adapter registry, and whatever credential its resolved + * profile names is stored. A profile naming no reference authenticates through + * the provider's own path (the Bedrock chain, Vertex ADC, a gateway that needs + * nothing), as does a live route with no settings address at all, so neither + * owes this page a key. + * @param row - one joined provider row. + * @returns whether the user already has this provider to talk to. + */ +export function providerUsable(row: ProviderRow): boolean { + if (!row.entry.active) return false + if (row.apiKeyEnv === undefined) return true + return row.credential?.configured === true +} + +/** First-run onboarding readiness derived only from the shared Models join. */ +export type OnboardingReadiness = | { kind: 'loading' } | { kind: 'adapter-absent' } - | { kind: 'configured' } + | { kind: 'provider-ready' } | { kind: 'credential-missing' } | { kind: 'unavailable' reason: | 'load-failed' | 'provider-inactive' - | 'settings-unavailable' - | 'credential-ref-unavailable' | 'credentials-unavailable' | 'settings-read-only' | 'credential-read-only' } /** - * Project official-DeepSeek readiness from the provider/settings/credential - * join used by the Models page. A missing official configurable-provider + * Project first-run readiness from the provider/settings/credential join used + * by the Models page. The step exists to leave the user with a model to talk + * to, so ANY usable provider ends it; only when none exists does the official + * DeepSeek route — the one route the prompt can offer a key field for — decide + * whether prompting can help. A missing official configurable-provider * declaration means the adapter is not repairable by navigating to Models. * @param state - current shared Models join snapshot. * @returns the onboarding state without reading a parallel fact source. */ -export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness { +export function onboardingReadiness(state: ModelsSettingsState): OnboardingReadiness { if ((state.status === 'idle' || state.status === 'loading') && state.rows.length === 0) { return { kind: 'loading' } } @@ -224,6 +241,7 @@ export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness reason: 'load-failed', } } + if (state.rows.some(providerUsable)) return { kind: 'provider-ready' } const row = state.rows.find(candidate => candidate.entry.provider === 'deepseek-official' && candidate.entry.settingsNs === 'llm-deepseek' @@ -235,33 +253,14 @@ export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness reason: 'provider-inactive', } } - if (!row.configured) { - return { - kind: 'unavailable', - reason: 'settings-unavailable', - } - } - if (row.apiKeyEnv === undefined) { - return { - kind: 'unavailable', - reason: 'credential-ref-unavailable', - } - } - if (state.credentialError !== null) { + // Past the usable gate an active route names a reference it has no stored + // credential for, so the remaining questions are all about that credential. + if (state.credentialError !== null || row.credential === undefined) { return { kind: 'unavailable', reason: 'credentials-unavailable', } } - if (row.credential === undefined) { - return { - kind: 'unavailable', - reason: 'credentials-unavailable', - } - } - if (row.credential.configured) { - return { kind: 'configured' } - } if (!state.writable) { return { kind: 'unavailable', diff --git a/packages/client/ui-models/tests/components.client.spec.tsx b/packages/client/ui-models/tests/components.client.spec.tsx index b1582a5fb8..01f0a32349 100644 --- a/packages/client/ui-models/tests/components.client.spec.tsx +++ b/packages/client/ui-models/tests/components.client.spec.tsx @@ -23,6 +23,8 @@ afterEach(cleanup) const t: ModelsSectionInjected['t'] = key => en[key] const OPENAI_TARGET = { provider: 'openai', displayName: 'openai' } const openaiCopy = (template: string): string => providerCopy(template, OPENAI_TARGET) +const DEEPSEEK_TARGET = { provider: 'deepseek-official', displayName: 'DeepSeek' } +const deepSeekCopy = (template: string): string => providerCopy(template, DEEPSEEK_TARGET) /** Open one row's capacity disclosure (1-based, as the labels read). */ function expandRow(position: number): void { @@ -181,8 +183,8 @@ function scriptedFace(overrides: { type WireFace = ConstructorParameters[0] -async function mountSection(overrides: Parameters[0] = {}) { - const { face, update, replace, mutate, set, unset } = scriptedFace(overrides) +async function mountFace(scripted: ReturnType) { + const { face, update, replace, mutate, set, unset } = scripted const controller = new ModelsSettingsStore(face as unknown as WireFace) await controller.load() const injected: ModelsSectionInjected = { @@ -195,6 +197,34 @@ async function mountSection(overrides: Parameters[0] = {}) return { view, face, update, replace, mutate, set, unset, controller } } +async function mountSection(overrides: Parameters[0] = {}) { + return mountFace(scriptedFace(overrides)) +} + +/** + * Mount for a user who cannot reach any provider yet: no credential is stored + * anywhere, so the whole-section DeepSeek route owns the first-run setup card. + */ +async function mountFirstRun(overrides: Parameters[0] = {}) { + const scripted = scriptedFace(overrides) + scripted.face.credentials.describe.mockImplementation((payload: { refs: string[] }) => + Promise.resolve(ok({ + credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])), + }))) + return mountFace(scripted) +} + +/** + * Mount and open the DeepSeek editor. The shared fixture already has a usable + * openai route, so DeepSeek is an ordinary row whose card opens through Edit + * rather than by itself. + */ +async function mountDeepSeekCard(overrides: Parameters[0] = {}) { + const mounted = await mountSection(overrides) + fireEvent.click(screen.getByRole('button', { name: deepSeekCopy(en.editProvider) })) + return mounted +} + describe('ModelsSection', () => { it('renders nothing before the slot injects its dependencies', () => { const uninjected = {} as ModelsSectionProps @@ -202,20 +232,32 @@ describe('ModelsSection', () => { expect(document.body.textContent).toBe('') }) - it('renders the unkeyed whole-section provider as an open setup card beside the rows', async () => { - await mountSection() - // DeepSeek has no configured credential and no stored apiKey → setup card. + it('renders the unkeyed whole-section provider as an open setup card in the first-run posture', async () => { + await mountFirstRun() + // Nothing is reachable yet, and DeepSeek has no configured credential and + // no stored apiKey → setup card. expect(screen.getByText('DeepSeek')).toBeTruthy() expect(screen.getByLabelText(en.keyInput)).toBeTruthy() expect(screen.getByText('openai')).toBeTruthy() expect(screen.queryByText('Active')).toBeNull() expect(screen.queryByText('Inactive')).toBeNull() + expect(screen.getByText(en.add)).toBeTruthy() + }) + + it('leaves the unkeyed provider a plain row once another provider is usable', async () => { + await mountSection() + // openai's key is stored, so the user is not blocked and nothing on the + // page opens itself over them. + expect(screen.queryByLabelText(en.keyInput)).toBeNull() const configured = screen.getByRole('img', { name: en.credentialConfigured }) expect(configured.getAttribute('title')).toBe(en.credentialConfigured) expect(configured.className).toContain('credentialDotConfigured') expect(configured.closest('li')?.textContent).toContain('openai') - expect(screen.queryByRole('img', { name: en.credentialMissing })).toBeNull() - expect(screen.getByText(en.add)).toBeTruthy() + const missing = screen.getByRole('img', { name: en.credentialMissing }) + expect(missing.closest('li')?.textContent).toContain('DeepSeek') + // The card is still one click away. + fireEvent.click(screen.getByRole('button', { name: deepSeekCopy(en.editProvider) })) + expect(screen.getByLabelText(en.keyInput)).toBeTruthy() }) it('marks only a confirmed missing reference and leaves native or unavailable state unmarked', async () => { @@ -241,7 +283,7 @@ describe('ModelsSection', () => { }) it('turns the setup card into a row once the credential reports configured', async () => { - const { face } = await mountSection() + const { face } = await mountFirstRun() face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({ credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: true, writable: true }])), }))) @@ -259,7 +301,7 @@ describe('ModelsSection', () => { expect(screen.queryByLabelText(en.keyInput)).toBeNull() }) - it('decides setup need from the joined credential state', () => { + it('decides setup need from the joined credential state and the first-run posture', () => { const entry = { provider: 'p', displayName: 'p', settingsNs: 'llm-deepseek', settingsPath: [], active: true } const row = (credential: ProviderRow['credential']): ProviderRow => ({ entry, @@ -268,10 +310,13 @@ describe('ModelsSection', () => { apiKeyEnv: 'X', credential, }) - expect(needsSetup(row(undefined))).toBe(true) - expect(needsSetup(row({ configured: true, writable: true }))).toBe(false) + expect(needsSetup(row(undefined), false)).toBe(true) + expect(needsSetup(row({ configured: true, writable: true }), false)).toBe(false) const nested = { ...row(undefined), entry: { ...entry, settingsPath: ['providers', 'x'] } } - expect(needsSetup(nested)).toBe(false) + expect(needsSetup(nested, false)).toBe(false) + // A user who can already reach some provider is not in the first-run + // posture, so nothing on the page opens itself. + expect(needsSetup(row(undefined), true)).toBe(false) }) it('derives conventional credential references from route ids', () => { @@ -296,7 +341,7 @@ describe('ModelsSection', () => { }) it('stores a typed key write-only from the setup card without touching settings', async () => { - const { set, update, face } = await mountSection() + const { set, update, face } = await mountFirstRun() const key = screen.getByLabelText(en.keyInput) fireEvent.change(key, { target: { value: ' sk-live ' } }) fireEvent.click(screen.getByText(en.apply)) @@ -311,7 +356,7 @@ describe('ModelsSection', () => { }) it('applies customized deepseek fields as path ops', async () => { - const { mutate } = await mountSection({ + const { mutate } = await mountDeepSeekCard({ mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) @@ -332,7 +377,7 @@ describe('ModelsSection', () => { }) it('materializes inherited models and adds an arbitrary DeepSeek id', async () => { - const { mutate } = await mountSection({ + const { mutate } = await mountDeepSeekCard({ mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) @@ -366,7 +411,7 @@ describe('ModelsSection', () => { }) it('rejects duplicate DeepSeek model ids before writing', async () => { - const { mutate } = await mountSection() + const { mutate } = await mountDeepSeekCard() fireEvent.click(screen.getByText(en.customized)) fireEvent.click(screen.getByText(en.addModel)) const ids = screen.getAllByLabelText(new RegExp(en.modelId)) @@ -436,7 +481,7 @@ describe('ModelsSection', () => { }) it('accepts a suffixed context window and stores the plain count', async () => { - const { mutate } = await mountSection({ + const { mutate } = await mountDeepSeekCard({ mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) @@ -476,7 +521,7 @@ describe('ModelsSection', () => { }) it('keeps unreadable context-window text on screen and refuses the write', async () => { - const { mutate } = await mountSection() + const { mutate } = await mountDeepSeekCard() fireEvent.click(screen.getByText(en.customized)) expandRow(1) expandRow(2) @@ -539,7 +584,7 @@ describe('ModelsSection', () => { // The regression: one active buffer meant editing a second row displaced // the first, which then fell back to rendering its stored NaN as `NaN` — // losing the text the user was told they could still correct. - await mountSection() + await mountDeepSeekCard() fireEvent.click(screen.getByText(en.customized)) expandRow(1) expandRow(2) @@ -553,7 +598,7 @@ describe('ModelsSection', () => { }) it('re-keys the typed text around a removed row', async () => { - await mountSection() + await mountDeepSeekCard() fireEvent.click(screen.getByText(en.customized)) const windows = (): HTMLInputElement[] => capacityInputs(en.contextWindow) const removeRow = (at: number): void => { @@ -587,7 +632,7 @@ describe('ModelsSection', () => { // The regression: reset removed the override but left the buffer, so an // inherited row displayed text no settings layer stores — and because an // unreadable buffer never settles, it stayed there indefinitely. - const { mutate } = await mountSection({ + const { mutate } = await mountDeepSeekCard({ mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) @@ -605,12 +650,12 @@ describe('ModelsSection', () => { // Reset put the draft back where it started, so Apply writes nothing at // all rather than persisting whatever the stale text had parsed to. fireEvent.click(screen.getByText(en.apply)) - await waitFor(() => { expect(screen.getByText(en.apply)).toBeTruthy() }) + await waitFor(() => { expect(screen.queryByText(en.apply)).toBeNull() }) expect(mutate).not.toHaveBeenCalled() }) it('edits an output cap per model and carries its text across a removal', async () => { - const { mutate } = await mountSection({ + const { mutate } = await mountDeepSeekCard({ mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) @@ -644,7 +689,7 @@ describe('ModelsSection', () => { }) it('settles a pasted id and refuses whitespace that would never match', async () => { - await mountSection() + await mountDeepSeekCard() fireEvent.click(screen.getByText(en.customized)) const ids = screen.getAllByLabelText(new RegExp(en.modelId)) fireEvent.change(ids[0] as HTMLInputElement, { target: { value: ' deepseek-v4-flash ' } }) @@ -681,7 +726,7 @@ describe('ModelsSection', () => { }) it('can empty and reset the model override, then clear optional fields without dropping hidden data', async () => { - const { mutate } = await mountSection({ + const { mutate } = await mountDeepSeekCard({ mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) @@ -715,7 +760,7 @@ describe('ModelsSection', () => { it('clears an inherited override with an unset op, never a whole-section replace', async () => { // A whole-section replace would clobber sibling overrides to clear one field. - const { replace, update, mutate } = await mountSection() + const { replace, update, mutate } = await mountDeepSeekCard() fireEvent.click(screen.getByText(en.customized)) const url = screen.getByLabelText(en.baseUrl) expect(url.value).toBe('https://base') @@ -762,7 +807,7 @@ describe('ModelsSection', () => { }) it('rejects an invalid draft before writing', async () => { - const { update } = await mountSection() + const { update } = await mountDeepSeekCard() fireEvent.click(screen.getByText(en.customized)) fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'not-a-url' } }) fireEvent.click(screen.getByText(en.apply)) @@ -772,19 +817,17 @@ describe('ModelsSection', () => { it('edits a pi-ai profile with the curated fields only', async () => { const { mutate } = await mountSection() - fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement) + fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) })) // The configured credential shows as the stored placeholder. - const keys = await screen.findAllByLabelText(en.keyInput) - const editorKey = keys[keys.length - 1] as HTMLInputElement + const editorKey = await screen.findByLabelText(en.keyInput) await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyStored) }) // pi-ai carries Base URL too: the stored override shows as the value and // the effective profile endpoint as its placeholder source. - fireEvent.click(screen.getAllByText(en.customized)[1] as HTMLElement) - const urls = screen.getAllByLabelText(en.baseUrl) - expect(urls).toHaveLength(2) - expect((urls[1] as HTMLInputElement).value).toBe('https://proxy') - fireEvent.change(urls[1] as HTMLInputElement, { target: { value: 'https://proxy/v2' } }) - fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) + fireEvent.click(screen.getByText(en.customized)) + const url = screen.getByLabelText(en.baseUrl) + expect(url.value).toBe('https://proxy') + fireEvent.change(url, { target: { value: 'https://proxy/v2' } }) + fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) // Only the edited field travels: apiKeyEnv and headers were already stored // with these values, so no op restates them. @@ -803,14 +846,12 @@ describe('ModelsSection', () => { expect(pick.value).toBe('anthropic') // A dormant profile has no endpoint anywhere: the pi-ai placeholder // falls back to the provider-default wording. - fireEvent.click(screen.getAllByText(en.customized)[1] as HTMLElement) - const urls = screen.getAllByLabelText(en.baseUrl) - expect((urls[1] as HTMLInputElement).placeholder).toBe(en.baseUrlDefault) - const keys = screen.getAllByLabelText(en.keyInput) - const addKey = keys[keys.length - 1] as HTMLInputElement + fireEvent.click(screen.getByText(en.customized)) + expect(screen.getByLabelText(en.baseUrl).placeholder).toBe(en.baseUrlDefault) + const addKey = screen.getByLabelText(en.keyInput) expect(addKey.placeholder).toBe(en.keyPlaceholderNative) fireEvent.change(addKey, { target: { value: 'sk-ant' } }) - fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) + fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) expect(mutate.mock.calls[0]?.[0]).toEqual({ ns: 'llm-pi-ai', @@ -824,7 +865,7 @@ describe('ModelsSection', () => { const { mutate, set } = await mountSection() fireEvent.click(screen.getByText(en.add)) await screen.findByLabelText(en.provider) - fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) + fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) expect(mutate.mock.calls[0]?.[0]).toEqual({ ns: 'llm-pi-ai', @@ -855,9 +896,8 @@ describe('ModelsSection', () => { const { face, controller } = await mountSection({ mutate, set }) fireEvent.click(screen.getByText(en.add)) await screen.findByLabelText(en.provider) - const keys = screen.getAllByLabelText(en.keyInput) - fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-ant' } }) - fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-ant' } }) + fireEvent.click(screen.getByText(en.apply)) await screen.findByText('credential store unavailable') expect(mutate).toHaveBeenCalledOnce() face.settings.describe.mockResolvedValue(ok({ @@ -867,7 +907,7 @@ describe('ModelsSection', () => { })) await act(async () => { await controller.load() }) expect(controller.store.getSnapshot().namespaces.get('llm-pi-ai')?.revision).toBe(1) - fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) + fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(set).toHaveBeenCalledTimes(2) }) expect(mutate).toHaveBeenCalledOnce() expect(set).toHaveBeenLastCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' }) @@ -883,10 +923,9 @@ describe('ModelsSection', () => { await waitFor(() => { expect(screen.getAllByText(content => content.includes(en.advancedHint)).length).toBeGreaterThan(0) }) - // The hint-only card cannot apply anything. - const applies = screen.getAllByText(en.apply) - expect((applies[applies.length - 1] as HTMLButtonElement).disabled).toBe(true) - expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1) + // The hint-only card cannot apply anything, and offers no key field. + expect(screen.getByText(en.apply).disabled).toBe(true) + expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0) }) it('surfaces a rejected settings write and never stores the key after it', async () => { @@ -895,9 +934,8 @@ describe('ModelsSection', () => { }) fireEvent.click(screen.getByText(en.add)) await screen.findByLabelText(en.provider) - const keys = screen.getAllByLabelText(en.keyInput) - fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-x' } }) - fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-x' } }) + fireEvent.click(screen.getByText(en.apply)) await screen.findByText(/unknown pi-ai provider/) expect(set).not.toHaveBeenCalled() }) @@ -930,7 +968,7 @@ describe('ModelsSection', () => { it('tells the user to reopen when another writer moved the namespace first', async () => { // The stale-draft overwrite: two tabs open the same card, the other saves, // and this one must be refused rather than replay its opening snapshot. - const { set } = await mountSection({ + const { set } = await mountDeepSeekCard({ mutate: vi.fn(() => Promise.resolve(fail('changed since it was read', 'settings-conflict'))), }) fireEvent.click(screen.getByText(en.customized)) @@ -944,7 +982,7 @@ describe('ModelsSection', () => { // A transport failure (disconnect, or the 403 a non-loopback browser now // gets on the whole configuration plane) rejects rather than returning a // failed envelope: without a catch the card would stay busy forever. - await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('connection lost'))) }) + await mountDeepSeekCard({ mutate: vi.fn(() => Promise.reject(new Error('connection lost'))) }) fireEvent.click(screen.getByText(en.customized)) fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://next' } }) fireEvent.click(screen.getByText(en.apply)) @@ -954,7 +992,7 @@ describe('ModelsSection', () => { }) it('surfaces a shadowed credential write on the card', async () => { - await mountSection({ + await mountFirstRun({ set: vi.fn(() => Promise.resolve(fail('credentials: DEEPSEEK_API_KEY is shadowed by the read-only environment', 'credential-rejected'))), }) const key = screen.getByLabelText(en.keyInput) @@ -971,9 +1009,8 @@ describe('ModelsSection', () => { configured: ref === 'OPENAI_API_KEY', source: 'env', writable: false, }])), }))) - fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement) - const keys = await screen.findAllByLabelText(en.keyInput) - const editorKey = keys[keys.length - 1] as HTMLInputElement + fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) })) + const editorKey = await screen.findByLabelText(en.keyInput) await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyEnvLocked) }) expect(editorKey.disabled).toBe(true) }) @@ -981,12 +1018,11 @@ describe('ModelsSection', () => { it('keeps a failed credential describe silent and the input usable', async () => { const { face, set } = await mountSection() face.credentials.describe.mockImplementation(() => Promise.resolve(fail('down', 'internal')) as never) - fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement) - const keys = await screen.findAllByLabelText(en.keyInput) - const editorKey = keys[keys.length - 1] as HTMLInputElement + fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) })) + const editorKey = await screen.findByLabelText(en.keyInput) expect(editorKey.placeholder).toBe(en.keyPlaceholderNative) fireEvent.change(editorKey, { target: { value: 'sk-live' } }) - fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) + fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) }) }) @@ -1085,15 +1121,15 @@ describe('ModelsSection', () => { it('toggles the row editor closed on a second edit click and on cancel', async () => { const { update } = await mountSection() - const edit = screen.getAllByText(en.edit)[0] as HTMLElement + const edit = screen.getByRole('button', { name: openaiCopy(en.editProvider) }) fireEvent.click(edit) - await waitFor(() => { expect(screen.getAllByLabelText(en.keyInput).length).toBe(2) }) + await waitFor(() => { expect(screen.queryAllByLabelText(en.keyInput).length).toBe(1) }) fireEvent.click(edit) - expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1) + expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0) fireEvent.click(edit) - await waitFor(() => { expect(screen.getAllByLabelText(en.keyInput).length).toBe(2) }) - fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement) - expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1) + await waitFor(() => { expect(screen.queryAllByLabelText(en.keyInput).length).toBe(1) }) + fireEvent.click(screen.getByText(en.cancel)) + expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0) expect(update).not.toHaveBeenCalled() }) @@ -1101,11 +1137,34 @@ describe('ModelsSection', () => { await mountSection() fireEvent.click(screen.getByText(en.add)) await screen.findByLabelText(en.provider) - fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement) + fireEvent.click(screen.getByText(en.cancel)) await screen.findByText(en.add) expect(screen.queryByLabelText(en.provider)).toBeNull() }) + it('collapses the setup card on cancel without disturbing another open card', async () => { + // The regression: the setup card shared the row/add/declare close handler, + // so cancelling it discarded the add card's draft while staying open itself. + await mountFirstRun() + expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1) + fireEvent.click(screen.getByText(en.add)) + await screen.findByLabelText(en.provider) + expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(2) + + // The setup card is the first one on the page, above the add block. + fireEvent.click(screen.getAllByText(en.cancel)[0] as HTMLElement) + // The add card kept its draft… + expect(screen.getByLabelText(en.provider)).toBeTruthy() + // …and DeepSeek collapsed to an ordinary row carrying the missing-key dot. + expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1) + expect(screen.getAllByRole('img', { name: en.credentialMissing }) + .some(dot => dot.closest('li')?.textContent?.includes('DeepSeek') === true)).toBe(true) + // Its card reopens through Edit, which closes the add card as any row does. + fireEvent.click(screen.getByRole('button', { name: deepSeekCopy(en.editProvider) })) + expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1) + expect(screen.queryByLabelText(en.provider)).toBeNull() + }) + it('loads on first render of an idle controller', async () => { const { face } = scriptedFace() const controller = new ModelsSettingsStore(face as unknown as WireFace) diff --git a/packages/client/ui-models/tests/readiness.client.spec.ts b/packages/client/ui-models/tests/readiness.client.spec.ts index 8647a2da83..f01e821767 100644 --- a/packages/client/ui-models/tests/readiness.client.spec.ts +++ b/packages/client/ui-models/tests/readiness.client.spec.ts @@ -1,8 +1,8 @@ -/** Pure official-DeepSeek readiness projection over the shared Models join. */ +/** Pure first-run readiness projection over the shared Models join. */ import { describe, expect, it } from 'vitest' import type { CredentialView } from '@deepseek-ai/dsh-api-remotes/client' import type { ModelsSettingsState, ProviderRow } from '../src/client/store.ts' -import { deepSeekReadiness } from '../src/client/store.ts' +import { onboardingReadiness, providerUsable } from '../src/client/store.ts' const missingCredential: CredentialView = { configured: false, writable: true } @@ -23,6 +23,24 @@ function row(overrides: Partial = {}): ProviderRow { } } +/** A second provider the user configured themselves. */ +function otherRow(overrides: Partial = {}): ProviderRow { + return { + entry: { + provider: 'hfai', + displayName: 'HFAI', + settingsNs: 'llm-pi-ai', + settingsPath: ['providers', 'hfai'], + active: true, + }, + configured: true, + removable: true, + apiKeyEnv: 'HFAI_API_KEY', + credential: { configured: true, source: 'file', writable: true }, + ...overrides, + } +} + function state(overrides: Partial = {}): ModelsSettingsState { return { status: 'ready', @@ -35,12 +53,25 @@ function state(overrides: Partial = {}): ModelsSettingsStat } } -describe('deepSeekReadiness', () => { +describe('providerUsable', () => { + it('requires a registered route and a stored key for every named reference', () => { + expect(providerUsable(otherRow())).toBe(true) + expect(providerUsable(otherRow({ entry: { ...otherRow().entry, active: false } }))).toBe(false) + expect(providerUsable(otherRow({ credential: missingCredential }))).toBe(false) + expect(providerUsable(otherRow({ credential: undefined }))).toBe(false) + }) + + it('treats a reference-free registered route as provider-native authentication', () => { + expect(providerUsable(otherRow({ apiKeyEnv: undefined, credential: undefined }))).toBe(true) + }) +}) + +describe('onboardingReadiness', () => { it('waits for the first join and skips onboarding when the adapter directory entry is absent', () => { - expect(deepSeekReadiness(state({ status: 'idle', rows: [] }))).toEqual({ kind: 'loading' }) - expect(deepSeekReadiness(state({ status: 'loading', rows: [] }))).toEqual({ kind: 'loading' }) - expect(deepSeekReadiness(state({ rows: [] }))).toEqual({ kind: 'adapter-absent' }) - expect(deepSeekReadiness(state({ + expect(onboardingReadiness(state({ status: 'idle', rows: [] }))).toEqual({ kind: 'loading' }) + expect(onboardingReadiness(state({ status: 'loading', rows: [] }))).toEqual({ kind: 'loading' }) + expect(onboardingReadiness(state({ rows: [] }))).toEqual({ kind: 'adapter-absent' }) + expect(onboardingReadiness(state({ rows: [row({ entry: { ...row().entry, @@ -51,45 +82,47 @@ describe('deepSeekReadiness', () => { }) it('reports a missing writable effective credential', () => { - expect(deepSeekReadiness(state())).toEqual({ kind: 'credential-missing' }) + expect(onboardingReadiness(state())).toEqual({ kind: 'credential-missing' }) + }) + + it('ends onboarding once any other registered provider can serve requests', () => { + expect(onboardingReadiness(state({ rows: [row(), otherRow()] }))).toEqual({ kind: 'provider-ready' }) + // A provider the user cannot reach yet leaves the prompt in place. + expect(onboardingReadiness(state({ + rows: [row(), otherRow({ credential: missingCredential })], + }))).toEqual({ kind: 'credential-missing' }) }) it('accepts file and process-environment credentials without prompting', () => { - expect(deepSeekReadiness(state({ + expect(onboardingReadiness(state({ rows: [row({ credential: { configured: true, source: 'file', writable: true } })], - }))).toEqual({ kind: 'configured' }) - expect(deepSeekReadiness(state({ + }))).toEqual({ kind: 'provider-ready' }) + expect(onboardingReadiness(state({ rows: [row({ credential: { configured: true, source: 'env', writable: false } })], - }))).toEqual({ kind: 'configured' }) + }))).toEqual({ kind: 'provider-ready' }) }) - it('turns missing capabilities and inconsistent descriptors into diagnostics', () => { - expect(deepSeekReadiness(state({ status: 'error', error: 'settings down' }))).toEqual({ + it('turns missing capabilities into diagnostics that never block the product', () => { + expect(onboardingReadiness(state({ status: 'error', error: 'settings down' }))).toEqual({ kind: 'unavailable', reason: 'load-failed', }) - expect(deepSeekReadiness(state({ + expect(onboardingReadiness(state({ rows: [row({ entry: { ...row().entry, active: false } })], }))).toEqual({ kind: 'unavailable', reason: 'provider-inactive' }) - expect(deepSeekReadiness(state({ - rows: [row({ configured: false })], - }))).toEqual({ kind: 'unavailable', reason: 'settings-unavailable' }) - expect(deepSeekReadiness(state({ - rows: [row({ apiKeyEnv: undefined })], - }))).toEqual({ kind: 'unavailable', reason: 'credential-ref-unavailable' }) - expect(deepSeekReadiness(state({ + expect(onboardingReadiness(state({ credentialError: 'credentials service is absent', }))).toEqual({ kind: 'unavailable', reason: 'credentials-unavailable', }) - expect(deepSeekReadiness(state({ + expect(onboardingReadiness(state({ rows: [row({ credential: undefined })], }))).toEqual({ kind: 'unavailable', reason: 'credentials-unavailable' }) - expect(deepSeekReadiness(state({ + expect(onboardingReadiness(state({ rows: [row({ credential: { configured: false, writable: false } })], }))).toEqual({ kind: 'unavailable', reason: 'credential-read-only' }) - expect(deepSeekReadiness(state({ writable: false }))).toEqual({ + expect(onboardingReadiness(state({ writable: false }))).toEqual({ kind: 'unavailable', reason: 'settings-read-only', }) diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index 20c1584c1a..7bc0c5aced 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -115,6 +115,15 @@ background: var(--dsw-alias-interactive-bg-hover); } +.denseList .item { + min-height: 34px; + padding-block: 5px; +} + +.denseList .label { + padding-block: 4px; +} + .list.compactList, .submenu.compactList { min-width: 164px; diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index ea7e51b478..46c30b8afb 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -62,6 +62,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * @param props.anchor - the trigger element (rendered in place). * @param props.items - selectable rows and optional separators. * @param props.selectedId - row shown as selected. + * @param props.selectedIds - rows shown as selected when a menu contains independent option groups. * @param props.onSelect - row click callback (not called for disabled rows or submenu parents that only open children). * @param props.onClose - invoked on outside click or Escape. * @param props.align - list alignment against the anchor (default 'start'). @@ -74,6 +75,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * both trigger and list for the pointer grace (default false keeps it open * until outside click/Escape/selection). The grace makes the 4px trigger->list * gap and a brief overshoot survivable; coming back cancels the close. + * @param props.dense - reduce vertical row spacing without changing the standard typography or card width. * @param props.compact - use reduced menu typography and spacing. * @param props.getAnchorRect - portal mode only: supply the anchor rect * directly (e.g. from a host-owned trigger button) instead of measuring the @@ -85,18 +87,20 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * by a hairline; they stay visible while the items above scroll. * @returns anchor wrapper with the conditional list. */ -export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, compact = false, getAnchorRect, footer, className }: { +export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, getAnchorRect, footer, className }: { open: boolean anchor: ReactNode items: readonly MenuEntry[] footer?: readonly MenuEntry[] selectedId?: string | undefined + selectedIds?: readonly string[] | undefined onSelect: (id: string) => void onClose: () => void align?: 'start' | 'end' side?: 'bottom' | 'top' | 'right' portal?: boolean closeOnPointerLeave?: boolean + dense?: boolean compact?: boolean getAnchorRect?: () => DOMRect | null className?: string @@ -204,6 +208,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align } const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 const subOpen = hasSub && openSubmenuId === entry.id + const selected = entry.id === selectedId || selectedIds?.includes(entry.id) === true return (
    {entry.icon}} {entry.label} {/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */} - {entry.id === selectedId && } + {selected && } {subOpen && entry.submenu !== undefined && (
    @@ -260,7 +265,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align const list = open && (
    last-seen never reaches the host. diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index a9fb927305..11b0aa142c 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -侧边栏插件:真实 Host Workspace 按稳定的 Host 顺序排列;每个 Workspace 按自身顺序包含其 `sessionIds`,并以 `parentId` 嵌套;不属于任何 Workspace 的会话显示在末尾的 `Ungrouped` 分区。搜索、状态点以及折叠到布局拥有的 56px 轨道,都只属于呈现层。约定:[slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)。 +侧边栏外壳插件:负责字标、New Session 操作、布局持有的折叠控件、可感知滚动的区域 seat,以及固定在底部的 Settings seat。[ui-workspace](../ui-workspace/README.md) 持有渲染到 `sidebar.workspaces` 的 Workspace 与 Session 浏览器;本包既不派生其中的行,也不持有其视图偏好。折叠到布局拥有的 56px 轨道仍属于本地呈现行为。约定:[slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)。 -New Session 会启动运行时的页面局部前端 Session Intent;真实 Workspace 的「+」会启动一项以该 Workspace 为目标的 Intent。Workspace 标题栏的「+」打开 ui-workspace 的共享选择器,选择结果同样以一个前端会话为目标。Workspace Intent 不会出现在侧边栏中。 +New Session 会启动运行时的页面局部前端 Session Intent。运行时优先使用作用域操作明确指定的 Workspace,否则使用当前 Session 所属 Workspace,再否则使用最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。Workspace 专属控件与共享选择器由 ui-workspace 持有。 -`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 和 `useWorkspaces` 钩子、已声明的 `sidebar.workspace` 与 `sidebar.settings` 子 slot,以及注入的 `startSession`、`open` 和侧边栏切换回调。这里没有插件 store:`deriveGroups` 消费对象层快照与组件局部的展开/搜索状态。 +`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 和 `useWorkspaces` 钩子、已声明的 `sidebar.workspaces` 与 `sidebar.settings` 子 slot,以及注入的 `startSession` 与侧边栏切换回调。这里没有插件 store。 栏内的滚动条是一种指针可供性:只要指针不在栏内,外壳就把 ui-theme 的[滚动条间接层](../ui-theme/README.md)重新绑定为 `transparent`;指针离开后滑块再保留 2 秒,因此没人指向的列表不会带着滚动条。避免行位移的空间预留属于滚动区域本身([ui-workspace](../ui-workspace/README.md)),所以显示滑块不会引起重排。 @@ -25,5 +25,5 @@ New Session 会启动运行时的页面局部前端 Session Intent;真实 Work ## 已知限制与暂缓事项 - **Session 状态点渲染由 [ui-workspace](../ui-workspace/README.md) 持有**:没有可用的 done/error 通知数据源。 -- **分组只支持 Workspace**:Update 和 Status 不是可用策略。 +- **Workspace 浏览行为由组合持有**:分组、排序、搜索与行状态都属于 [ui-workspace](../ui-workspace/README.md),不属于此外壳。 - **「New task completed」未读标记是本地查看状态**:完成时间 > 上次查看时间这一事实永远不会到达宿主。 diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 67310853a2..17333b5ccc 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -167,7 +167,7 @@ gap: 6px; height: 38px; padding: 8px 16px; - margin: 0 2px 20px; /* bottom: former headerBlock padBottom 12 + root gap 8 */ + margin: 0 2px 8px; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; @@ -215,16 +215,20 @@ min-height: 0; display: flex; flex-direction: column; + margin-left: -4px; margin-right: calc(-1 * var(--dsh-sidebar-inline-padding)); + padding-left: 4px; overflow: hidden; } .collapsed .regionArea { + margin-left: 0; margin-right: 0; + padding-left: 0; } /* Foot seat: a pure layout socket pinned under the region; the ui-settings - trigger row inside owns its own geometry (49px wide row / 36px rail + trigger row inside owns its own geometry (38px wide row / 36px rail circle) and hover chrome. */ .footArea { flex: none; diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index 7b30e4232e..4da4d14eed 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -58,8 +58,8 @@ export interface SidebarSettingsOwnerProps { export type SidebarRootInjected = { /** * Start a New Session: with a workspace, reuse-or-create its blank session - * and open it; without one, clear the selection into the New Session pure - * view state (the conversation.empty seat). + * and open it; without one, inherit the current Session Workspace, then the + * recent Workspace, or clear into the New Session pure view when none exist. */ startSession: (workspaceId?: WorkspaceId) => void /** Toggle the sidebar column through the layout service. */ diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index 3d7ed23aa4..a9706c3e99 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -30,7 +30,7 @@ export function apply(ctx: ClientContext): void { const injectProps = (): SidebarRootInjected => ({ // The shell's New Session button rides the runtime's shared action - // (recent-Workspace targeting; explicit Workspace wins for scoped actions). + // (current Session Workspace, then recent Workspace). startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) }, toggleSidebar: () => { ctx.layout.toggleSidebar() }, }) diff --git a/packages/client/ui-sidebar/tests/sidebar-styles.client.spec.ts b/packages/client/ui-sidebar/tests/sidebar-styles.client.spec.ts index 63721258c9..c4abce1911 100644 --- a/packages/client/ui-sidebar/tests/sidebar-styles.client.spec.ts +++ b/packages/client/ui-sidebar/tests/sidebar-styles.client.spec.ts @@ -30,9 +30,13 @@ describe('SidebarRoot.module.css inset', () => { const root = declarations('.root') expect(root?.get('--dsh-sidebar-inline-padding')).toBe('12px') expect(root?.get('padding')).toBe('6px var(--dsh-sidebar-inline-padding)') + expect(declarations('.regionArea')?.get('margin-left')).toBe('-4px') + expect(declarations('.regionArea')?.get('padding-left')).toBe('4px') expect(declarations('.regionArea')?.get('margin-right')).toBe( 'calc(-1 * var(--dsh-sidebar-inline-padding))', ) + expect(declarations('.collapsed .regionArea')?.get('margin-left')).toBe('0') + expect(declarations('.collapsed .regionArea')?.get('padding-left')).toBe('0') expect(declarations('.collapsed .regionArea')?.get('margin-right')).toBe('0') }) }) diff --git a/packages/client/ui-trajectory/tests/client-bundle.client.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.client.spec.ts index eebef86099..2efedc8d3e 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.client.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.client.spec.ts @@ -83,7 +83,7 @@ describe('tsdown client artifact', () => { // Paging is session-owned; this registration-only probe never renders the // entry, so the binding stays deliberately empty. The locale plugin backs // the locale-aware view tab label (its settings scope needs a connection - // handle and the forwarded-event port). + // handle and the Host-facing settings/remote seams). ctx.provide('sessions', { binding: () => undefined }) ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) ctx.provide('remote', { $on: () => () => {} } as never) diff --git a/packages/client/ui-workflow-run/README.i18n.yaml b/packages/client/ui-workflow-run/README.i18n.yaml index 3d6294e997..6baade6354 100644 --- a/packages/client/ui-workflow-run/README.i18n.yaml +++ b/packages/client/ui-workflow-run/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-workflow-run/README.md -README.md: 66539e0c16ac4102f9e1fe881106e6881b36a7d5 -README.zh.md: a803857af24802e8a4645c4d5aca56c04424c85e +README.md: 489715c51759b1efd2da68d3bd3e0f7788ce7ecd +README.zh.md: 326a7ae4e4b8eaad43ca7ad0d22145452af6a734 diff --git a/packages/client/ui-workflow-run/README.md b/packages/client/ui-workflow-run/README.md index 66539e0c16..489715c517 100644 --- a/packages/client/ui-workflow-run/README.md +++ b/packages/client/ui-workflow-run/README.md @@ -12,7 +12,7 @@ Phase groups come only from members that actually started. Exact phase strings s ## Presentation and navigation -The run and each phase have independent disclosure state. The run uses a 32-pixel `--dsw-alias-bg-module-platform` row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column. A running run initially expands; a terminal run loaded from history initially collapses. Local choices survive data updates while the keyed node remains mounted and reset only on a full remount. +The run and each phase derive disclosure control from their current lifecycle facts. The run stays expanded while its own status is running, failed, cancelled, or interrupted, or while any phase contains such a member; each affected phase also stays expanded. Forced-open headers are static expanded rows without button, keyboard, or `aria-expanded` promises. A phase folds once when every member completes, and the run folds once when it and every phase complete. Each clean layer then exposes an ordinary disclosure control whose local choice survives clean rerenders; new activity takes control again, and a remount derives the initial state from current data. The run uses a 32-pixel `--dsw-alias-bg-module-platform` row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column. A member opens a child Session only while every current fact agrees: the member is running, the child id is in the ordinary Session list, the row has `origin: 'subagent'`, its `parentId` is the current Session, and the list row is still running. Underlined member text is the only visible navigation affordance; keyboard focus draws a two-pixel business-primary ring around the name area, while status copy remains `Running`. The component calls only the injected ordinary `sessions.open(id)` action; remote, addressed-only, wrong-parent, or terminal rows remain non-interactive. diff --git a/packages/client/ui-workflow-run/README.zh.md b/packages/client/ui-workflow-run/README.zh.md index a803857af2..326a7ae4e4 100644 --- a/packages/client/ui-workflow-run/README.zh.md +++ b/packages/client/ui-workflow-run/README.zh.md @@ -12,7 +12,7 @@ ## 展示与导航 -运行和每个阶段分别拥有本地 disclosure 状态。运行使用 32 像素 `--dsw-alias-bg-module-platform` 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。运行中记录首次挂载时展开,从历史加载的终态记录首次挂载时折叠。只要 keyed 节点仍挂载,本地选择就在数据更新时保持;只有完整 remount 才重新初始化。 +运行和每个阶段都从当前生命周期事实派生 disclosure 控制。运行自身处于运行中、失败、已取消或已中断,或者任一阶段包含这些状态的成员时,运行保持展开;受影响的阶段也保持展开。强制展开的标题行只是静态展开行,不承诺按钮、键盘操作或 `aria-expanded`。阶段在全部成员完成时折叠一次;运行在自身和全部阶段都完成时折叠一次。每个干净层级随后恢复普通 disclosure 控件,其本地选择在干净状态的 rerender 中保持;新活动会重新取得控制,remount 则从当前数据派生初始状态。运行使用 32 像素 `--dsw-alias-bg-module-platform` 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。 只有所有实时事实同时成立时,成员才可打开子 Session:成员仍在运行、子 id 位于普通 Session 列表、列表行为 `origin: 'subagent'`、`parentId` 等于当前 Session,且列表行仍标记运行。带下划线的成员文字是唯一可见导航提示;键盘聚焦时,名称区显示 2 像素 business-primary 焦点环,右侧状态仍只显示“运行中”。组件只调用注入的普通 `sessions.open(id)`;远程、仅地址化、父级不符或终态的行都不可交互。 diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css index 77145ee06a..fdb54fdde2 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css @@ -14,7 +14,6 @@ padding: 0 8px; border-radius: 8px; background: var(--dsw-alias-bg-module-platform); - cursor: pointer; } .runHeader:focus-visible { @@ -78,7 +77,6 @@ width: 100%; min-width: 0; height: 32px; - cursor: pointer; } .phaseHeader:focus-visible { diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx index fcb36da7a3..c58399c583 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx @@ -1,6 +1,7 @@ -import { useState } from 'react' +import { useState, type ReactNode } from 'react' import { - DisclosureRow, IconChevronRightOutline14, StateDot, type StateDotState, + DisclosureRow, IconChevronRightOutline14, StateDot, + type DisclosureRowProps, type StateDotState, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { shallowEqual, type SessionId, type SessionListState } from '@deepseek-ai/dsh-client-runtime/client' @@ -62,6 +63,36 @@ function memberCount(count: number, t: WorkflowRunPanelProps['t']): string { return t(count === 1 ? 'run.members.one' : 'run.members.other', { count }) } +function phaseRequiresExpansion(phase: WorkflowRunPhaseData): boolean { + return phase.members.some(member => member.status !== 'completed') +} + +type StatusDisclosureProps = Omit + +/* v8 ignore next -- DisclosureRow requires the callback but cannot invoke it when expandable is false. */ +const forcedOpenToggle = (): void => {} + +function ManualDisclosure(props: StatusDisclosureProps) { + const [open, setOpen] = useState(false) + return ( + { setOpen(value => !value) }} + /> + ) +} + +function StatusDisclosure({ cleanCycleKey, requiresExpansion, ...props }: StatusDisclosureProps & { + /** Remount a clean Phase when its append-only member count changes between batched renders. */ + readonly cleanCycleKey?: number | undefined + readonly requiresExpansion: boolean +}) { + if (!requiresExpansion) return + return +} + function phaseStatusSummary(members: readonly WorkflowRunMemberData[], t: WorkflowRunPanelProps['t']): string { const counts = new Map() for (const member of members) counts.set(member.status, (counts.get(member.status) ?? 0) + 1) @@ -97,21 +128,19 @@ function navigableMembers( return result } -function RunHeader({ count, name, onToggle, open, status, t }: { +function RunHeader({ children, count, name, requiresExpansion, status, t }: { + readonly children: ReactNode readonly count: number readonly name: string - readonly onToggle: () => void - readonly open: boolean + readonly requiresExpansion: boolean readonly status: WorkflowRunStatus readonly t: WorkflowRunPanelProps['t'] }) { return ( - } title={t('run.title', { name })} - open={open} - expandable - onToggle={onToggle} + requiresExpansion={requiresExpansion} expandOnRowClick previewChevron={false} keepContentWhenOpen @@ -128,7 +157,9 @@ function RunHeader({ count, name, onToggle, open, status, t }: { )} - /> + > + {children} + ) } @@ -168,15 +199,12 @@ function PhaseSection({ phase, navigable, openSession, t }: { readonly openSession: WorkflowRunInjected['openSession'] readonly t: WorkflowRunPanelProps['t'] }) { - const [open, setOpen] = useState(false) - const toggle = (): void => { setOpen(value => !value) } return ( - } title={readablePhase(phase.phase, t)} - open={open} - expandable - onToggle={toggle} + cleanCycleKey={phase.members.length} + requiresExpansion={phaseRequiresExpansion(phase)} expandOnRowClick previewChevron={false} keepContentWhenOpen @@ -203,14 +231,15 @@ function PhaseSection({ phase, navigable, openSession, t }: { /> ))}
    - + ) } -/** Render one durable workflow run with independent run and phase disclosure. */ +/** Render one durable workflow run with status-driven run and phase disclosure. */ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t }: WorkflowRunPanelProps) { - const [open, setOpen] = useState(() => node.data.status === 'running') - const memberCount = node.data.phases.reduce((count, phase) => count + phase.members.length, 0) + const totalMembers = node.data.phases.reduce((count, phase) => count + phase.members.length, 0) + const requiresExpansion = node.data.status !== 'completed' + || node.data.phases.some(phaseRequiresExpansion) const navigable = useSessions( sessions => navigableMembers(sessions, node.data.phases, sessionId), shallowEqual, @@ -218,14 +247,12 @@ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t return (
    { setOpen(value => !value) }} - /> - {open && ( + >
    {node.data.phases.length === 0 ? {t('run.empty')} @@ -239,7 +266,7 @@ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t /> ))}
    - )} +
    ) } diff --git a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx index 196bdf1144..e4fefa5ad7 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx @@ -301,90 +301,170 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi } describe('WorkflowRunPanel', () => { - it('defaults running runs open, terminal history closed, and keeps the current choice across data updates', () => { + it('forces running run and phase content open without false disclosure controls', () => { + const view = render() + expect(screen.getByText('worker')).toBeTruthy() + expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull() + expect(screen.queryByRole('button', { name: /Research/ })).toBeNull() + const rows = [...view.container.querySelectorAll('[data-disclosure-row]')] + expect(rows).toHaveLength(2) + for (const row of rows) { + expect(row.getAttribute('role')).toBeNull() + expect(row.getAttribute('tabindex')).toBeNull() + expect(row.getAttribute('aria-expanded')).toBeNull() + expect(row.getAttribute('data-expandable')).toBeNull() + } + }) + + it('folds each clean transition once and preserves review choices until activity returns', () => { const running: WorkflowRunChatData = { name: 'audit', status: 'running', phases: [phase()], } const view = render() - expect(screen.getByText('未分阶段')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: /^audit/ })) - expect(screen.queryByText('未分阶段')).toBeNull() + const phaseCompleted: WorkflowRunChatData = { + ...running, + phases: [phase({ + members: [{ + seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed', + }], + })], + } + view.rerender() + const phaseHeader = screen.getByRole('button', { name: /未分阶段/ }) + expect(phaseHeader.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByText('done')).toBeNull() + fireEvent.click(phaseHeader) + expect(screen.getByText('done')).toBeTruthy() - const terminal: WorkflowRunChatData = { ...running, status: 'completed' } - view.rerender() + const completed: WorkflowRunChatData = { ...phaseCompleted, status: 'completed' } + view.rerender() + const runHeader = screen.getByRole('button', { name: /^audit/ }) + expect(runHeader.getAttribute('aria-expanded')).toBe('false') expect(screen.queryByText('未分阶段')).toBeNull() + fireEvent.keyDown(runHeader, { key: 'ArrowDown' }) + expect(runHeader.getAttribute('aria-expanded')).toBe('false') + fireEvent.keyDown(runHeader, { key: 'Enter' }) + expect(runHeader.getAttribute('aria-expanded')).toBe('true') + const completedPhase = screen.getByRole('button', { name: /未分阶段/ }) + fireEvent.keyDown(completedPhase, { key: 'Enter' }) + expect(screen.getByText('done')).toBeTruthy() + fireEvent.keyDown(runHeader, { key: ' ' }) + expect(runHeader.getAttribute('aria-expanded')).toBe('false') + fireEvent.keyDown(runHeader, { key: ' ' }) + expect(runHeader.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) + expect(screen.getByText('done')).toBeTruthy() - cleanup() - render() + const cleanUpdate: WorkflowRunChatData = { + ...completed, + phases: [phase({ + members: [{ + seq: 1, label: 'reviewed', childId: 'child-1' as SessionId, status: 'completed', + }], + })], + } + view.rerender() + expect(screen.getByText('reviewed')).toBeTruthy() + + view.rerender() + expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull() + expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull() + expect(screen.getByText('worker')).toBeTruthy() + view.rerender() + expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('false') expect(screen.queryByText('未分阶段')).toBeNull() }) - it('supports root keyboard disclosure and renders a zero-member running state', () => { - render( { + const firstMember = { + seq: 1, label: 'first', childId: 'child-1' as SessionId, status: 'completed' as const, + } + const phaseClean: WorkflowRunChatData = { + name: 'phase-cycle', status: 'running', + phases: [phase({ members: [firstMember] })], + } + const phaseView = render() + fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) + expect(screen.getByText('first')).toBeTruthy() + phaseView.rerender() - const header = screen.getByRole('button', { name: /^keyboard/ }) - expect(header.getAttribute('aria-expanded')).toBe('true') - fireEvent.keyDown(header, { key: 'ArrowDown' }) - expect(header.getAttribute('aria-expanded')).toBe('true') - fireEvent.keyDown(header, { key: 'Enter' }) - expect(header.getAttribute('aria-expanded')).toBe('false') - fireEvent.keyDown(header, { key: ' ' }) - expect(header.getAttribute('aria-expanded')).toBe('true') - expect(screen.getByText('Research')).toBeTruthy() - expect(screen.getByText('运行中 1')).toBeTruthy() - const phaseHeader = screen.getByRole('button', { name: /Research/ }) - fireEvent.keyDown(phaseHeader, { key: 'ArrowDown' }) - expect(phaseHeader.getAttribute('aria-expanded')).toBe('false') - fireEvent.keyDown(phaseHeader, { key: 'Enter' }) - expect(phaseHeader.getAttribute('aria-expanded')).toBe('true') - fireEvent.keyDown(phaseHeader, { key: ' ' }) - expect(phaseHeader.getAttribute('aria-expanded')).toBe('false') + expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByText('first')).toBeNull() + expect(screen.queryByText('second')).toBeNull() + }) - cleanup() - render() + it('derives the zero-member running and completed states from the current run status', () => { + const running: WorkflowRunChatData = { name: 'empty', status: 'running', phases: [] } + const view = render() + expect(screen.queryByRole('button', { name: /^empty/ })).toBeNull() + expect(screen.getByText('没有启动成员')).toBeTruthy() + view.rerender() + const header = screen.getByRole('button', { name: /^empty/ }) + expect(header.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByText('没有启动成员')).toBeNull() + fireEvent.click(header) expect(screen.getByText('没有启动成员')).toBeTruthy() }) - it('keeps phase disclosure independent and preserves empty versus absent names', () => { + it.each(['failed', 'cancelled', 'interrupted'] as const)( + 'bubbles a %s member to the run and keeps a matching run outcome open', + (status) => { + const memberView = render() + expect(screen.queryByRole('button', { name: /^member-outcome/ })).toBeNull() + expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull() + expect(screen.getByText(status)).toBeTruthy() + memberView.unmount() + + render() + expect(screen.queryByRole('button', { name: /^run-outcome/ })).toBeNull() + expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByText('done')).toBeNull() + }, + ) + + it('keeps clean sibling phases independent and preserves empty versus absent names', () => { render() - fireEvent.click(screen.getByRole('button', { name: /空阶段名/ })) - expect(screen.getByText('空成员名')).toBeTruthy() - expect(screen.queryByText('second')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) + expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull() + const cleanPhase = screen.getByRole('button', { name: /空阶段名/ }) + expect(cleanPhase.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull() + expect(screen.queryByText('空成员名')).toBeNull() expect(screen.getByText('second')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: /空阶段名/ })) + fireEvent.click(cleanPhase) + expect(screen.getByText('空成员名')).toBeTruthy() + expect(screen.getByText('second')).toBeTruthy() + fireEvent.click(cleanPhase) expect(screen.queryByText('空成员名')).toBeNull() expect(screen.getByText('second')).toBeTruthy() }) - it('covers the Figma completed, failed/cancelled, and interrupted state boards', () => { - const completed: WorkflowRunChatData = { - name: 'repo-audit', status: 'completed', - phases: [phase({ - members: [{ seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' }], - })], - } - const completedView = render() - const completedHeader = screen.getByRole('button', { name: /^repo-audit/ }) - expect(completedHeader.getAttribute('aria-expanded')).toBe('false') - fireEvent.click(completedHeader) - expect(completedHeader.getAttribute('aria-expanded')).toBe('true') - completedView.unmount() - + it('renders mixed and interrupted aggregate status while attention stays visible', () => { const mixed: WorkflowRunChatData = { name: 'repo-audit', status: 'failed', phases: [phase({ @@ -395,8 +475,6 @@ describe('WorkflowRunPanel', () => { })], } const mixedView = render() - fireEvent.click(screen.getByRole('button', { name: /^repo-audit/ })) - fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) expect(screen.getByText('失败 1 · 已取消 1')).toBeTruthy() expect([...mixedView.container.querySelectorAll('[data-member-status]')] .map(row => row.getAttribute('data-member-status'))).toEqual(['failed', 'cancelled']) @@ -404,28 +482,18 @@ describe('WorkflowRunPanel', () => { expect(mixedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(1) mixedView.unmount() - const interrupted: WorkflowRunChatData = { + const interruptedView = render() - fireEvent.click(screen.getByRole('button', { name: /^repo-audit/ })) + phases: [phase({ + members: [ + { seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' }, + { seq: 2, label: 'interrupted', childId: 'child-2' as SessionId, status: 'interrupted' }, + ], + })], + })} />) expect(screen.getByText('已完成 1 · 已中断 1')).toBeTruthy() expect(interruptedView.container.querySelector('[data-run-status="interrupted"]')).toBeTruthy() - expect(interruptedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(1) + expect(interruptedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(2) }) it('opens only a running ordinary-list subagent proven to have this parent', () => { @@ -434,7 +502,6 @@ describe('WorkflowRunPanel', () => { } const openSession = vi.fn() render() - fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) fireEvent.click(screen.getByRole('button', { name: '打开 worker' })) expect(openSession).toHaveBeenCalledWith('child-1') }) @@ -464,7 +531,6 @@ describe('WorkflowRunPanel', () => { })], } render() - fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) expect(screen.queryByRole('button', { name: '打开 worker' })).toBeNull() cleanup() }) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 1a1dd057f0..7a50937c41 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md -README.md: 1ec07bd41e72bb5a26b2cfc3bf90e57e7d92db08 -README.zh.md: 8edd0fed6d3bdefd9df339a8b3d0588533264538 +README.md: 9d7d4d77cc064146f1fdaed615509215c64308fc +README.zh.md: ca35d7cd2e7ff176f4ea40d1e9a3a6d1a7457462 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 1ec07bd41e..9d7d4d77cc 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -4,7 +4,9 @@ English | [中文](README.zh.md) Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar's `sidebar.workspaces` slot, while `WorkspacePicker` fills the page-local Session Intent hero's `conversation.hero.workspace` slot; both surfaces use the same Workspace menu and add flow. -The browser renders grouped or flat Session rows from the global runtime hooks and owns the Workspace add/rename and in-Workspace reorder flows. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event. +The browser renders grouped or flat Session rows from the global runtime hooks and owns Workspace add/rename/reorder plus Session reorder. A Workspace remembers whether it is closed or showing Sessions; an open Workspace shows five Sessions by default, offers a transient **Show more** control for the remainder, and returns to five after the whole Workspace is closed and reopened. Creating a Session from a Workspace row first opens that group so the new row remains visible when the Session state arrives. Once the Workspace list baseline is ready, browser-persisted expansion and Session-order records retain only current Workspace ids plus Ungrouped and the flat-list account. View options combine grouping with one browser-persisted Session order per account: real Workspaces initialize from `WorkspaceView.sessionIds`, while Ungrouped and the cross-Workspace flat list initialize from recency. **Manual** and **Last updated** apply in either presentation. Entering Last updated performs a complete recency sort and later user prompts or steers promote their Session once, while entering Manual preserves every current position and disables later promotion. Dragging edits the current order in either mode; Manual-mode drags for real Workspaces also update the Host Session account, while Ungrouped and flat-list orders remain browser-local because neither has one Workspace account. Flat rows omit the empty leading status slot because they have no parent hierarchy, but retain it when a Session status is visible. Workspace drag order is Host-durable in either Session order mode. + +Collapsed search is one header action beside the view and add actions. Activating it expands the field across the header; an outside click collapses only a query that is empty after trimming, while the clear control always resets and collapses it. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event. The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Distinct canonical paths remain separate id-keyed Workspaces when their basenames and display titles match; the sidebar hover detail exposes the full path. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default, under which the sidebar header drops its add button rather than offering a dead one). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 8edd0fed6d..ca35d7cd2e 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -4,7 +4,9 @@ 共享 Workspace 浏览器与选择器插件。`WorkspaceBrowser` 填充侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 则填充页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot;两个界面使用同一套 Workspace 菜单和添加流程。 -该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 +该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名/重排序以及 Session 重排序。每个 Workspace 会记住自身是关闭还是显示 Session;打开后默认显示五条 Session,其余条目通过临时的**展开其余**控件显示,而关闭并重新打开整个 Workspace 后会恢复为五条。从 Workspace 行创建 Session 时会先打开该分组,使 Session 状态到达后新行保持可见。Workspace 列表基线就绪后,浏览器持久化的展开状态与 Session 顺序记录只保留当前 Workspace id、Ungrouped 和单列表记账。视图选项把分组方式和每个记账各自的一份浏览器持久化 Session 顺序放在一起:真实 Workspace 从 `WorkspaceView.sessionIds` 初始化,Ungrouped 和跨 Workspace 的单列表则从最近更新时间顺序初始化。**手动排序**和**最近更新**在两种呈现方式下都可用。进入最近更新时会执行一次完整的时间排序,后续 user prompt 或 steer 会将对应 Session 置顶一次;进入手动排序则保留所有当前位置并停用后续置顶。两种模式下的拖拽都会编辑当前顺序;真实 Workspace 在手动模式下的拖拽还会更新 Host Session 记账,而 Ungrouped 和单列表因没有单一 Workspace 记账,其顺序始终只保存在浏览器本地。单列表没有父级层次,因此不显示空的左侧状态槽;Session 存在可见状态时仍保留该槽。无论采用哪种 Session 顺序,Workspace 拖拽顺序都由 Host 持久化。 + +折叠搜索是视图和添加操作旁的一枚区头按钮。激活后,输入框会扩展并占据区头;点击外部只会收起经清除首尾空白后为空的查询,而清除控件总会重置并收起搜索。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。不同的规范化路径即使 basename 和显示标题相同,仍会作为由 id 区分的独立 Workspace;侧边栏的悬停详情会显示完整路径。每个注册各自声明一个**目录流子 slot**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **添加工作区…** 操作仅在当前界面的 slot 被占用时渲染(每次菜单渲染读取占用状态;slot 为空意味着该组合没有目录选择能力——seam 文档化的无流程默认行为,此时侧边栏区头直接不渲染添加按钮,而非留下一个点了没反应的按钮)。本包持有触发与接纳:占用方通过 slot 的属主交互约定(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。空白的「新会话」行只是占位符:不渲染行菜单和时间标签(其中还没有发生任何事),重命名、fork 和归档都从首条提示词落地后才可用。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css index 6c6c44c2c7..a160f2ffd7 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -38,9 +38,8 @@ background: var(--dsw-alias-interactive-bg-hover); } -/* Section header: 36px, "Workspaces/Sessions" label + group-by / - new-workspace buttons; the right-anchored new-workspace button is the - row's rail survivor. */ +/* Section header: title, an inline search control, and the two trailing + actions. Expanding search collapses the action cluster and takes its room. */ .sectionHeader { flex: none; display: flex; @@ -48,7 +47,7 @@ justify-content: flex-end; gap: 4px; height: 36px; - padding-left: 12px; + padding-left: 4px; margin-bottom: 4px; box-sizing: border-box; border-radius: 12px; @@ -56,71 +55,118 @@ color: var(--dsw-alias-label-tertiary); } +.root:not(.rail) .sectionHeader { + margin-top: 2px; + margin-right: -4px; +} + .sectionLabel { - flex: 1; + flex: none; + max-width: 45%; min-width: 0; overflow: hidden; white-space: nowrap; line-height: 20px; + opacity: 1; + visibility: visible; + transition: + max-width 180ms var(--ds-ease-in-out), + margin-right 180ms var(--ds-ease-in-out), + opacity 120ms var(--ds-ease-in-out), + transform 180ms var(--ds-ease-in-out), + visibility 0s linear; } -/* 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. */ -.search { - --dsh-search-input-fill: var(--dsw-static-neutral-bluish-75); +.sectionLabelHidden { + max-width: 0; + margin-right: -4px; + opacity: 0; + transform: translateX(-4px); + visibility: hidden; + transition-delay: 0s, 0s, 0s, 0s, 180ms; +} + +.searchSlot { + flex: 1; + max-width: 28px; + min-width: 0; + display: flex; + align-items: center; + margin-left: auto; + padding-left: 0; + box-sizing: border-box; + transition: + max-width 180ms var(--ds-ease-in-out), + padding-left 180ms var(--ds-ease-in-out); +} + +.searchSlotExpanded { + max-width: 100%; + padding-left: 0; +} + +.headerActions { flex: none; display: flex; align-items: center; - gap: 8px; - height: 38px; - margin: 0 2px 12px; - padding: 0 14px; - box-sizing: border-box; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 12px; - background: var(--dsh-search-input-fill); - color: var(--dsw-alias-label-caption); + gap: 4px; + max-width: 60px; + opacity: 1; overflow: hidden; + visibility: visible; + transition: + max-width 180ms var(--ds-ease-in-out), + opacity 120ms var(--ds-ease-in-out), + transform 180ms var(--ds-ease-in-out), + visibility 0s linear; } -:global(body[data-ds-dark-theme]) .search { - --dsh-search-input-fill: var(--dsw-static-neutral-bluish-900); +.headerActionsHidden { + max-width: 0; + opacity: 0; + transform: translateX(4px); + visibility: hidden; + pointer-events: none; + transition-delay: 0s, 0s, 0s, 180ms; } -/* The capsule's leading icon: decorative while wide (pointer-events off so - clicks reach the input), the hit target in rail state. */ -.searchButton { +/* Inline search always fills the room between the title and trailing actions; + it grows farther right when the action cluster collapses. */ +.search { flex: none; - display: inline-flex; + display: flex; align-items: center; - justify-content: center; + gap: 0; + width: 100%; + height: 28px; + margin: 0; + padding: 0; + box-sizing: border-box; border: none; border-radius: 50%; - padding: 0; background: transparent; - pointer-events: none; - color: inherit; + cursor: text; + color: var(--dsw-alias-label-secondary); + overflow: hidden; + transition: + width 180ms var(--ds-ease-in-out), + padding 180ms var(--ds-ease-in-out), + border-color 180ms var(--ds-ease-in-out), + background-color 180ms var(--ds-ease-in-out); } -.searchInput { - flex: 1; - min-width: 0; - border: none; - outline: none; +.searchExpanded { + width: calc(100% + 4px); + height: 30px; + margin-inline: -2px; + padding: 0 4px 0 0; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 10px; background: transparent; - font-size: 14px; - line-height: 20px; - color: var(--dsw-alias-label-primary); + color: var(--dsw-alias-label-caption); } -.searchInput::placeholder { - color: var(--dsw-alias-label-tertiary); -} - -.clearButton { +.searchButton { flex: none; display: inline-flex; align-items: center; @@ -132,9 +178,66 @@ padding: 0; background: transparent; cursor: pointer; + color: inherit; +} + +.searchExpanded .searchButton { + width: 28px; + height: 30px; +} + +.searchButton:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.searchExpanded .searchButton:hover { + background: transparent; +} + +.searchInput { + flex: 1; + width: 0; + min-width: 0; + border: none; + outline: none; + background: transparent; + opacity: 0; + pointer-events: none; + font-size: 13px; + line-height: 18px; + color: var(--dsw-alias-label-primary); + transition: opacity 120ms var(--ds-ease-in-out); +} + +.searchExpanded .searchInput { + margin-left: -2px; + opacity: 1; + pointer-events: auto; +} + +.searchInput::placeholder { + color: var(--dsw-alias-label-tertiary); +} + +.clearButton { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + border-radius: 50%; + padding: 0; + background: transparent; + cursor: pointer; color: var(--dsw-alias-label-secondary); } +.clearButton:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + /* Rail variant (own .rail class from the wide owner prop — the region never reads the shell's class names): the two icon controls stack as 36x36 circles matching the shell's rail rhythm. */ @@ -144,6 +247,10 @@ margin-bottom: 12px; } +.rail .headerActions { + max-width: none; +} + .rail .iconButton { width: 36px; height: 36px; @@ -151,6 +258,7 @@ } .rail .search { + width: 36px; height: 36px; padding: 0; margin: 0 0 12px; @@ -162,8 +270,6 @@ .rail .searchButton { width: 36px; height: 36px; - pointer-events: auto; - cursor: pointer; color: var(--dsw-alias-label-primary); } @@ -177,12 +283,18 @@ min-height: 0; display: flex; flex-direction: column; + margin-left: -4px; margin-right: calc(-1 * var(--dsh-session-list-edge-inset)); - overflow: hidden; + padding-left: 4px; + /* The list remains the scroll clip. This seat stays visible so the + absolutely positioned first-boundary marker can occupy the header gap. */ + overflow: visible; } .rail .listArea { + margin-left: 0; margin-right: 0; + padding-left: 0; } /* Relative for the bottom fade overlay. */ @@ -194,14 +306,14 @@ position: relative; } -/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom, +/* Bottom fade: compact overlay pinned to the visible bottom, transparent -> sidebar fill so it tracks the theme. */ .fade { position: absolute; left: 0; right: var(--dsh-session-list-edge-inset); bottom: 0; - height: 72px; + height: 24px; background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill)); pointer-events: none; } @@ -223,15 +335,17 @@ flex: 1; min-height: 0; overflow-y: auto; + margin-left: -4px; margin-right: var(--dsh-session-list-scrollbar-offset); + padding-left: 4px; padding-right: calc( var(--dsh-session-list-edge-inset) - var(--dsh-session-list-scrollbar-width) - var(--dsh-session-list-scrollbar-offset) ); - /* Clears the 72px bottom fade overlay: at scroll end the last row sits + /* Clears the compact bottom fade overlay: at scroll end the last row sits above the gradient instead of under it. */ - padding-bottom: 48px; + padding-bottom: 16px; scrollbar-gutter: stable; } @@ -254,10 +368,84 @@ } /* One workspace section: header row + a compact expanded session run. */ +.groupSection { + position: relative; +} + .groupSection + .groupSection { margin-top: 4px; } +.listTopDropIndicator, +.workspaceDropBefore::before, +.workspaceDropAfter::after { + content: ''; + position: absolute; + z-index: 1; + left: 0; + right: 0; + height: 12px; + background: + linear-gradient( + 55deg, + transparent calc(50% - 1px), + var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px), + transparent calc(50% + 1px) + ) 0 0 / 5px 7px no-repeat, + linear-gradient( + 125deg, + transparent calc(50% - 1px), + var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px), + transparent calc(50% + 1px) + ) 0 5px / 5px 7px no-repeat, + linear-gradient( + var(--dsw-alias-state-business-primary) 0 0 + ) 4px 5px / calc(100% - 4px) 2px no-repeat; + pointer-events: none; +} + +/* The first insertion boundary keeps the same -8px coordinate as every + Workspace boundary, but lives outside the scrolling clip. */ +.listTopDropIndicator { + top: -8px; + left: 0; + right: var(--dsh-session-list-edge-inset); +} + +.listTopDropActive > .workspaceDropBefore:first-child::before { + display: none; +} + +.workspaceDropBefore::before { + top: -8px; +} + +.workspaceDropAfter::after { + bottom: -8px; +} + +.sessionOverflowButton { + width: 100%; + height: 28px; + border: none; + border-radius: 8px; + padding: 0 12px 0 28px; + background: transparent; + cursor: pointer; + text-align: left; + font-size: 12px; + color: var(--dsw-alias-label-tertiary); +} + +.groupSection > .sessionOverflowButton { + margin-top: 0; +} + +.sessionOverflowButton:hover { + background: transparent; + color: var(--dsw-alias-label-secondary); +} + .empty { padding: 16px 12px; color: var(--dsw-alias-label-tertiary); @@ -305,4 +493,12 @@ .wide { animation: none; } + + .search, + .sectionLabel, + .searchSlot, + .searchInput, + .headerActions { + transition: none; + } } diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index a049b3f3d1..e9d6ed192f 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -1,6 +1,6 @@ /** * The workspace/session browsing region filling the sidebar shell's - * `sidebar.workspaces` hole: section header (title + group-by + add + * `sidebar.workspaces` hole: section header (title + view options + add * workspace), search, the grouped tree or flat list, and the workspace * dialogs. Wide state renders the full browser; rail state renders the two * region icons (search / add workspace), each requesting shell expansion @@ -16,12 +16,13 @@ import { IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { - SessionSearchResultItem, WorkspaceId, WorkspaceView, + SessionId, SessionListState, SessionSearchResultItem, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' import type { WorkspaceBrowserProps } from './contract/slots.ts' -import type { SessionNode } from './tree.ts' +import type { SessionNode, SessionOrderBy } from './tree.ts' import { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from './tree.ts' import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './rows/Rows.tsx' +import { FLAT_SESSION_ORDER_KEY } from './stores.ts' import { WorkspacePickFlow } from './WorkspacePicker.tsx' import css from './WorkspaceBrowser.module.css' @@ -34,6 +35,8 @@ const EXPAND_SLIDE_MS = 300 const SEARCH_DEBOUNCE_MS = 250 /** `session.search` wire bound, measured in JavaScript UTF-16 code units. */ const SEARCH_QUERY_MAX_CODE_UNITS = 500 +/** Session rows visible per Workspace before the local overflow control. */ +const COLLAPSED_SESSION_LIMIT = 5 /** Keep controlled input and RPC payload inside the session.search wire contract. */ function sanitizeSearchQuery(value: string): string { @@ -46,15 +49,106 @@ function sanitizeSearchQuery(value: string): string { return withoutNul.slice(0, end) } -/** Immutable membership toggle for the local expansion arrays. */ +/** Immutable membership toggle for the local expand-all array. */ function toggled(list: readonly string[], key: string): string[] { return list.includes(key) ? list.filter(k => k !== key) : [...list, key] } -/** Group-by strategy menu; own open state so it resets with the wide chrome. */ -function GroupByMenu({ groupBy, onPick, t }: { +/** + * Accept the native drag at document level while a row drag is active: row + * hover still owns the insertion marker, and releasing outside the list must + * not be rendered as a rejected drop before dragend commits that last marker. + */ +function useNativeDragAcceptance(active: boolean): void { + useEffect(() => { + if (!active) return + const acceptDrag = (event: DragEvent): void => { + event.preventDefault() + if (event.dataTransfer !== null) event.dataTransfer.dropEffect = 'move' + } + const acceptDrop = (event: DragEvent): void => { event.preventDefault() } + document.addEventListener('dragover', acceptDrag) + document.addEventListener('drop', acceptDrop) + return () => { + document.removeEventListener('dragover', acceptDrag) + document.removeEventListener('drop', acceptDrop) + } + }, [active]) +} + +/** Reconcile a stored view order with the Workspace's current session account. */ +function reconciledSessionOrder(sessionIds: readonly SessionId[], stored: readonly string[] | undefined): SessionId[] { + if (stored === undefined) return [...sessionIds] + const byId = new Map(sessionIds.map(id => [id as string, id])) + const ordered: SessionId[] = [] + const included = new Set() + for (const key of stored) { + const id = byId.get(key) + if (id === undefined || included.has(key)) continue + ordered.push(id) + included.add(key) + } + for (const id of sessionIds) { + if (included.has(id)) continue + ordered.push(id) + } + return ordered +} + +/** Newest update first with stable Session identity as the tie-break. */ +function compareSessionRecency(a: SessionId, b: SessionId, byId: SessionListState['byId']): number { + const aUpdatedAt = byId[a]?.updatedAt ?? Number.NEGATIVE_INFINITY + const bUpdatedAt = byId[b]?.updatedAt ?? Number.NEGATIVE_INFINITY + if (aUpdatedAt !== bUpdatedAt) return bUpdatedAt - aUpdatedAt + return a < b ? -1 : 1 +} + +/** Reconcile one editable order account and apply its activity-promotion policy. */ +function nextSessionOrderAccount({ + sessionIds, previousOrder, previousUpdatedAt, list, orderBy, sortByRecency, +}: { + sessionIds: readonly SessionId[] + previousOrder: readonly string[] | undefined + previousUpdatedAt: Readonly> + list: SessionListState + orderBy: SessionOrderBy + sortByRecency: boolean +}): { order: SessionId[]; updatedAt: Record; changed: boolean } { + let order = reconciledSessionOrder(sessionIds, previousOrder) + if (sortByRecency) { + order.sort((a, b) => compareSessionRecency(a, b, list.byId)) + } else if (orderBy === 'updated') { + const promoted = sessionIds + .filter((id) => { + const session = list.byId[id] + return session !== undefined + && (previousUpdatedAt[id] === undefined || session.updatedAt > previousUpdatedAt[id]) + }) + .sort((a, b) => compareSessionRecency(a, b, list.byId)) + if (promoted.length > 0) { + const promotedIds = new Set(promoted) + order = [...promoted, ...order.filter(id => !promotedIds.has(id))] + } + } + const updatedAt: Record = {} + for (const id of sessionIds) { + const session = list.byId[id] + if (session !== undefined) updatedAt[id] = session.updatedAt + } + const orderChanged = previousOrder === undefined + || order.length !== previousOrder.length + || order.some((id, index) => id !== previousOrder[index]) + const timestampsChanged = Object.keys(updatedAt).length !== Object.keys(previousUpdatedAt).length + || Object.entries(updatedAt).some(([id, timestamp]) => previousUpdatedAt[id] !== timestamp) + return { order, updatedAt, changed: orderChanged || timestampsChanged } +} + +/** Grouping and ordering menu; own open state so it resets with the wide chrome. */ +function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: { groupBy: 'workspace' | 'flat' - onPick: (mode: 'workspace' | 'flat') => void + orderBy: SessionOrderBy + onGroupPick: (mode: 'workspace' | 'flat') => void + onOrderPick: (mode: SessionOrderBy) => void t: WorkspaceBrowserProps['t'] }) { const [open, setOpen] = useState(false) @@ -66,23 +160,28 @@ function GroupByMenu({ groupBy, onPick, t }: { { type: 'label' as const, id: 'group-by', text: t('groupBy.label') }, { id: 'workspace', label: t('groupBy.workspace') }, { id: 'flat', label: t('groupBy.flat') }, + { type: 'separator' as const, id: 'order-by-separator' }, + { type: 'label' as const, id: 'order-by', text: t('orderBy.label') }, + { id: 'manual', label: t('orderBy.manual') }, + { id: 'updated', label: t('orderBy.updated') }, ]} - selectedId={groupBy} + selectedIds={[groupBy, orderBy]} onSelect={(id) => { - /* v8 ignore next -- narrowing guard: the heading label is not selectable, so the only arriving ids are the two modes. */ - if (id === 'workspace' || id === 'flat') onPick(id) + if (id === 'workspace' || id === 'flat') onGroupPick(id) + else if (id === 'manual' || id === 'updated') onOrderPick(id) setOpen(false) }} align="end" + dense // Portal: the section header clips overflow, so an in-place list would // be cut off at the header's bounds. portal anchor={( - + + )} +
    + ) + })}
    ) } -/** The flat "In one list" body: every session a top-level row, newest-first. */ -function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, t }: Pick< - SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' | 't' +/** The flat "In one list" body: every session is one draggable top-level row. */ +function FlatList({ + useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, + orderBy, recentSessionOrder, recentSessionUpdatedAt, syncRecentSessions, setRecentSessionOrder, t, +}: Pick< + SessionTreeProps, + | 'useSessions' + | 'open' + | 'forkSession' + | 'onSessionRename' + | 'onSessionArchive' + | 'archivedSessionIds' + | 'orderBy' + | 'recentSessionOrder' + | 'recentSessionUpdatedAt' + | 'syncRecentSessions' + | 'setRecentSessionOrder' + | 't' >) { const list = useSessions(s => s) - const rows = useMemo(() => deriveFlat(list, archivedSessionIds), [list, archivedSessionIds]) + const baseRows = useMemo( + () => deriveFlat(list, archivedSessionIds), + [list, archivedSessionIds], + ) + const sessionIds = useMemo(() => baseRows.map(row => row.id), [baseRows]) + const previousOrderBy = useRef(orderBy) + useEffect(() => { + if (list.phase !== 'ready') return + const previousOrder = recentSessionOrder[FLAT_SESSION_ORDER_KEY] + const previousUpdatedAt = recentSessionUpdatedAt[FLAT_SESSION_ORDER_KEY] ?? {} + const switchedToUpdated = previousOrderBy.current !== 'updated' && orderBy === 'updated' + previousOrderBy.current = orderBy + const next = nextSessionOrderAccount({ + sessionIds, + previousOrder, + previousUpdatedAt, + list, + orderBy, + sortByRecency: orderBy === 'updated' && (previousOrder === undefined || switchedToUpdated), + }) + if (next.changed) { + syncRecentSessions(FLAT_SESSION_ORDER_KEY, next.order.map(id => id as string), next.updatedAt) + } + }, [list, orderBy, recentSessionOrder, recentSessionUpdatedAt, sessionIds, syncRecentSessions]) + const rows = useMemo(() => { + const byId = new Map(baseRows.map(row => [row.id, row])) + return reconciledSessionOrder(sessionIds, recentSessionOrder[FLAT_SESSION_ORDER_KEY]) + .flatMap((id) => { + const row = byId.get(id) + return row === undefined ? [] : [row] + }) + }, [baseRows, recentSessionOrder, sessionIds]) + const [drag, setDrag] = useState(null) + const dropCommitted = useRef(false) + useNativeDragAcceptance(drag !== null) + const commitDrag = (activeDrag: DragState, over: NonNullable): void => { + if (dropCommitted.current) return + dropCommitted.current = true + setDrag(null) + const targetIndex = rows.findIndex(row => row.id === over.id) + if (targetIndex === -1) return + const anchor = over.half === 'before' ? over.id : rows[targetIndex + 1]?.id + if (anchor === activeDrag.sessionId) return + const sourceIndex = rows.findIndex(row => row.id === activeDrag.sessionId) + const anchorIndex = anchor === undefined ? rows.length : rows.findIndex(row => row.id === anchor) + if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return + const nextOrder = rows.map(row => row.id).filter(id => id !== activeDrag.sessionId) + const insertAt = anchor === undefined ? nextOrder.length : nextOrder.indexOf(anchor) + nextOrder.splice(insertAt === -1 ? nextOrder.length : insertAt, 0, activeDrag.sessionId) + setRecentSessionOrder(FLAT_SESSION_ORDER_KEY, nextOrder.map(id => id as string)) + } const now = Date.now() return (
    @@ -244,19 +620,42 @@ function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionAr {rows.length === 0 && (
    {t('empty.none')}
    )} - {rows.map(node => ( - - ))} + {rows.map((node) => { + const active = drag !== null + return ( + { + dropCommitted.current = false + setDrag({ workspaceKey: FLAT_SESSION_ORDER_KEY, sessionId: node.id, over: null }) + }, + active, + marker: active && drag.over?.id === node.id ? drag.over.half : null, + hover: (half) => { + setDrag(current => current === null ? current : { ...current, over: { id: node.id, half } }) + }, + drop: (half) => { + if (drag !== null) commitDrag(drag, { id: node.id, half }) + }, + end: () => { + if (drag?.over !== null && drag?.over !== undefined) commitDrag(drag, drag.over) + else setDrag(null) + dropCommitted.current = false + }, + }} + t={t} + /> + ) + })}
    @@ -352,6 +751,7 @@ export function WorkspaceBrowser({ forkSession, renameWorkspace, deleteWorkspace, + insertWorkspaceBefore, archiveSession, insertSessionBefore, createWorkspace, @@ -362,14 +762,28 @@ export function WorkspaceBrowser({ t, }: WorkspaceBrowserProps) { const workspaces = useWorkspaces(state => state.items) + const workspacePhase = useWorkspaces(state => state.phase) const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds) // Live occupancy of this surface's directory-flow hole (the same source the // flow reads): a composition without a picking affordance can add nothing. const directoryFlowAvailable = useDirectoryFlow(occupied => occupied) const groupBy = useStore(s => s.groupBy) + const orderBy = useStore(s => s.orderBy) + const workspaceExpansion = useStore(s => s.workspaceExpansion) + const recentSessionOrder = useStore(s => s.recentSessionOrder) + const recentSessionUpdatedAt = useStore(s => s.recentSessionUpdatedAt) + useEffect(() => { + if (workspacePhase !== 'ready') return + actions.retainWorkspaceKeys([ + UNGROUPED_KEY, + FLAT_SESSION_ORDER_KEY, + ...workspaces.map(workspace => workspace.workspaceId as string), + ]) + }, [actions.retainWorkspaceKeys, workspacePhase, workspaces]) // The query outlives the tree and the input (both wide-only) so collapsing // does not silently drop an in-progress filter. const [query, setQuery] = useState('') + const [searchExpanded, setSearchExpanded] = useState(false) const normalizedQuery = sanitizeSearchQuery(query).trim() const [remoteSearch, setRemoteSearch] = useState({ query: '', @@ -377,6 +791,7 @@ export function WorkspaceBrowser({ items: [], hasMore: false, }) + const searchRoot = useRef(null) const searchInput = useRef(null) // Section-header + opens the picker menu (same popover in wide and rail // states; the menu anchors on this button). @@ -397,6 +812,23 @@ export function WorkspaceBrowser({ } }, [wide, searchOnExpand]) + useEffect(() => { + if (!wide || !searchExpanded || searchOnExpand) return + searchInput.current?.focus({ preventScroll: true }) + }, [wide, searchExpanded, searchOnExpand]) + + useEffect(() => { + if (!wide || !searchExpanded) return + const onClick = (event: MouseEvent): void => { + if (!(event.target instanceof Node) || searchRoot.current?.contains(event.target) === true) return + searchInput.current?.blur() + if (normalizedQuery !== '') return + setSearchExpanded(false) + } + document.addEventListener('click', onClick) + return () => { document.removeEventListener('click', onClick) } + }, [normalizedQuery, wide, searchExpanded]) + useEffect(() => { if (normalizedQuery === '') { setRemoteSearch({ query: '', status: 'idle', items: [], hasMore: false }) @@ -544,29 +976,96 @@ export function WorkspaceBrowser({
    {wide && ( - + {groupBy === 'flat' ? t('section.sessions') : t('section.workspaces')} )} - {wide && { actions.setGroupBy(mode) }} t={t} />} - {/* Adding is the button's one action, so a composition with no - picking affordance has nothing to offer here: the region hides the - button rather than leaving a dead one in the header. */} - {directoryFlowAvailable && ( - - - + + + + { setQuery(sanitizeSearchQuery(e.target.value)) }} + onKeyDown={(e) => { + if (e.key !== 'Escape') return + setQuery('') + setSearchExpanded(false) + }} + /> + {searchExpanded && ( + + )} +
    +
    )} +
    + {wide && ( + { actions.setGroupBy(mode) }} + onOrderPick={(mode) => { actions.setOrderBy(mode) }} + t={t} + /> + )} + {/* Adding is the button's one action, so a composition with no + picking affordance has nothing to offer here: the region hides the + button rather than leaving a dead one in the header. */} + {directoryFlowAvailable && ( + + + + )} +
    {/* Add flow + its error dialog (same package — direct composition). */} - {/* Expanded: the row is a click-to-focus field (the leading icon is - decorative). Rail: the icon is the region's search control. */} -
    { if (wide) searchInput.current?.focus() }}> - + {/* The collapsed rail keeps search as its own 36px control. */} + {!wide &&
    + - {wide && ( - { setQuery(sanitizeSearchQuery(e.target.value)) }} - /> - )} - {wide && query !== '' && ( - - )} -
    +
    } {/* Always-mounted seat keeps the region's flex slot while the list itself is wide-only. */} @@ -644,7 +1124,13 @@ export function WorkspaceBrowser({ ) : ( @@ -654,10 +1140,18 @@ export function WorkspaceBrowser({ onSessionArchive={onSessionArchive} forkSession={forkSession} workspaces={workspaces} + workspaceExpansion={workspaceExpansion} + setWorkspaceExpanded={actions.setWorkspaceExpanded} + recentSessionOrder={recentSessionOrder} + recentSessionUpdatedAt={recentSessionUpdatedAt} + syncRecentSessions={actions.syncRecentSessions} + setRecentSessionOrder={actions.setRecentSessionOrder} archivedSessionIds={archivedSessionIds} startSession={startSession} open={open} + insertWorkspaceBefore={insertWorkspaceBefore} insertSessionBefore={insertSessionBefore} + orderBy={orderBy} t={t} onRenameRequest={(workspaceId, currentTitle) => { setRenameTarget({ workspaceId, currentTitle }) diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index e1c41c9c17..8027a3623a 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -91,9 +91,9 @@ export type DirectoryPickingHooks = { */ export type WorkspaceBrowserInjected = DirectoryPickingInjected & { /** - * Start a New Session in a Workspace: reuse-or-create its blank session - * and open it; with no workspace, clear the selection into the New Session - * pure view state (the conversation.empty seat). + * Start a New Session in a Workspace: reuse-or-create its blank session and + * open it; without an explicit workspace, inherit the current Session + * Workspace, then the recent Workspace, or clear into the New Session view. */ startSession: (workspaceId?: WorkspaceId) => void /** Open a real Session. */ @@ -116,6 +116,11 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & { renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise /** Delete only a Host Workspace registration; directory and Session logs remain. */ deleteWorkspace: (workspaceId: WorkspaceId) => Promise + /** + * Reorder a Workspace in the durable registry display order. + * Omitted anchor appends to the end. + */ + insertWorkspaceBefore: (workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId) => Promise /** * Archive a Session into the registry-global set: hidden from grouping * surfaces, log and accounting slot retained. Archiving the current diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 5f499c4336..6b14243ecf 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -68,8 +68,8 @@ export function apply(ctx: ClientContext): void { const browserFlowSource = flowSource('sidebar.workspaces.directoryFlow') const pickerFlowSource = flowSource('conversation.hero.workspace.directoryFlow') const browserInjected = (): WorkspaceBrowserInjected => ({ - // Explicit group actions keep their target; unscoped New Session rides - // the runtime's shared action (recent-Workspace projection inside). + // Explicit group actions keep their target; unscoped New Session inherits + // the current Session Workspace before the recent-Workspace fallback. startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) }, open: (sessionId) => { ctx.sessions.open(sessionId) }, searchSessions, @@ -91,6 +91,9 @@ export function apply(ctx: ClientContext): void { }, renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) }, deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) }, + insertWorkspaceBefore: async (workspaceId, beforeWorkspaceId) => { + await ctx.workspaces.insertBefore(workspaceId, beforeWorkspaceId) + }, archiveSession: async (sessionId) => { await ctx.workspaces.archiveSession(sessionId) }, insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => { await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index 30fe6bfcc0..c08fd931de 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -10,14 +10,20 @@ export const zh = { 'session.new': '新会话', 'section.workspaces': '工作区', 'section.sessions': '会话', + 'viewOptions.label': '视图选项', 'groupBy.label': '分组方式', 'groupBy.workspace': '按工作区', 'groupBy.flat': '单列表', + 'orderBy.label': '排序方式', + 'orderBy.manual': '手动排序', + 'orderBy.updated': '最近更新', + 'sessions.expand': '展开其余 {n} 个会话', + 'sessions.collapse': '收起', 'empty.none': '暂无会话', 'empty.noMatches': '无匹配结果', 'workspace.add': '添加工作区', 'search.sessions.aria': '搜索会话', - 'search.placeholder': '搜索名称、关键词…', + 'search.placeholder': '搜索会话…', 'search.clear': '清除搜索', 'search.results.aria': '搜索结果', 'search.pending': '正在搜索会话历史…', @@ -73,14 +79,20 @@ export const en = { 'session.new': 'New Session', 'section.workspaces': 'Workspaces', 'section.sessions': 'Sessions', + 'viewOptions.label': 'View options', 'groupBy.label': 'Group by', 'groupBy.workspace': 'WorkSpace', 'groupBy.flat': 'In one list', + 'orderBy.label': 'Order by', + 'orderBy.manual': 'Manual', + 'orderBy.updated': 'Last updated', + 'sessions.expand': 'Show {n} more sessions', + 'sessions.collapse': 'Show less', 'empty.none': 'No sessions yet', 'empty.noMatches': 'No matches', 'workspace.add': 'Add workspace', 'search.sessions.aria': 'Search sessions', - 'search.placeholder': 'Search name, keywords...', + 'search.placeholder': 'Search sessions...', 'search.clear': 'Clear search', 'search.results.aria': 'Search results', 'search.pending': 'Searching session history…', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 612eb3e306..5dc899af45 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -1,5 +1,5 @@ -/* Tree rows (figma Cell set 14:3080): project 54px two-line, session 34px - single-line, radius 8, indent step 22px (16px slot + 6px gap). Hover swaps +/* Tree rows: project 34px, session 32px, radius 8, indent step 22px + (16px slot + 6px gap). Hover swaps are pure CSS: project folder -> chevron + action buttons; session time -> ellipsis button. */ @@ -21,7 +21,7 @@ } .sessionRow.selected { - background: var(--dsw-alias-interactive-bg-active); + background: var(--dsw-alias-interactive-bg-hover); } .searchResultRow { @@ -29,11 +29,11 @@ flex-direction: column; align-items: stretch; width: 100%; - min-height: 62px; + min-height: 48px; box-sizing: border-box; border: none; border-radius: 8px; - padding: 7px 8px; + padding: 4px 8px; background: transparent; cursor: pointer; text-align: left; @@ -45,7 +45,7 @@ } .searchResultRow.selected { - background: var(--dsw-alias-interactive-bg-active); + background: var(--dsw-alias-interactive-bg-hover); } .searchResultHeading { @@ -64,9 +64,16 @@ line-height: 20px; } +.searchResultMeta { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + margin-left: 20px; +} + .searchResultWorkspace, .searchResultSnippet { - margin-left: 20px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -75,21 +82,21 @@ } .searchResultWorkspace { + flex: none; + max-width: 40%; color: var(--dsw-alias-label-tertiary); } .searchResultSnippet { + flex: 1; + min-width: 0; color: var(--dsw-alias-label-secondary); } -/* Two-line row: the leading slot (folder/chevron), title, and trailing - actions all top-align on the 20px first text line (figma cell) — content - is 42px (20 + 2 + 20), so 6px vertical padding centers the block. */ +/* Compact one-line Workspace row after removing the session-count subtitle. */ .projectRow { - height: 54px; - align-items: flex-start; - padding-top: 6px; - padding-bottom: 6px; + height: 34px; + align-items: center; box-sizing: border-box; } @@ -99,7 +106,7 @@ /* Session cell (figma): pad 8, a 16px status slot, then a 4px title gap. */ .sessionRow { - height: 34px; + height: 32px; gap: 0; /* Mount fade: session rows appear by unfolding a group (or the tree mounting). Stable row keys keep already-visible rows from replaying it. */ @@ -110,6 +117,10 @@ margin: 0 6px 0 4px; } +.flatSessionRowWithoutStatus .title { + margin-left: 0; +} + @keyframes row-in { from { opacity: 0; } } @@ -133,11 +144,11 @@ white-space: nowrap; } - .folderActive { color: var(--dsw-alias-state-business-primary); } + /* Project leading slot: folder by default, expand arrow on row hover. */ .projectRow .chevron { display: none; } .projectRow:hover .chevron { display: inline-flex; } @@ -233,14 +244,46 @@ background: var(--dsw-alias-interactive-bg-hover); } -/* Drag reorder insert line (workspace-group session rows): 2px accent above or - below the hovered row, drawn with box-shadow so no layout shift. */ -.sessionRow.dropBefore { - box-shadow: 0 -2px 0 0 var(--dsw-alias-state-business-primary); +/* Session drag insert marker: a leading chevron and 2px rule between rows, + absolutely positioned so it neither resembles a row border nor changes layout. */ +.sessionRow.dropBefore, +.sessionRow.dropAfter { + position: relative; } -.sessionRow.dropAfter { - box-shadow: 0 2px 0 0 var(--dsw-alias-state-business-primary); +.sessionRow.dropBefore::before, +.sessionRow.dropAfter::after { + content: ''; + position: absolute; + z-index: 1; + left: 0; + right: 4px; + height: 12px; + background: + linear-gradient( + 55deg, + transparent calc(50% - 1px), + var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px), + transparent calc(50% + 1px) + ) 0 0 / 5px 7px no-repeat, + linear-gradient( + 125deg, + transparent calc(50% - 1px), + var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px), + transparent calc(50% + 1px) + ) 0 5px / 5px 7px no-repeat, + linear-gradient( + var(--dsw-alias-state-business-primary) 0 0 + ) 4px 5px / calc(100% - 4px) 2px no-repeat; + pointer-events: none; +} + +.sessionRow.dropBefore::before { + top: -7px; +} + +.sessionRow.dropAfter::after { + bottom: -7px; } /* Hover-card body (figma 169:16903): dark surface, fixed colors both themes. */ diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 71c0b05af5..481e0f0e47 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -67,29 +67,60 @@ function WorkspaceHoverContent({ label, cwd, createdAt, t }: { } /** - * Project (workspace) header row: 54px, folder + title + session count; + * Row drag wiring supplied by the tree owner. `drop` reports the half of the + * row where the pointer released so the owner can resolve an insert anchor. + */ +export interface RowDragProps { + /** Start dragging this row. */ + start: () => void + /** A compatible row drag is in flight. */ + active: boolean + /** Current marker on this row: insert line above, below, or none. */ + marker: 'before' | 'after' | null + /** Report the hovered half while a compatible drag passes over this row. */ + hover: (half: 'before' | 'after') => void + drop: (half: 'before' | 'after') => void + end: () => void +} + +/** Drag lifecycle owned by a workspace row; its enclosing group owns hit testing. */ +interface WorkspaceRowDragProps { + start: () => void + end: () => void +} + +/** Pointer-position half of a row (insert line above or below). */ +function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' { + const rect = e.currentTarget.getBoundingClientRect() + return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' +} + +/** + * Project (workspace) header row: folder + title; * hover reveals the chevron and create button, and dwelling on a real * Workspace shows its hover card (the ungrouped bucket has none). * `containsCurrent` arrives on the node (derivation fact, no renderer scan). * @param props.group - derived group node. * @param props.onToggle - expand/collapse the group. * @param props.onCreate - start a frontend Session inside this Workspace. + * @param props.drag - optional workspace-row drag wiring. * @param props.t - the browser root's locale seat. * @returns the row element. */ -export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: { +export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }: { group: GroupNode onToggle: () => void onCreate: () => void /** Real-Workspace actions; absent for the ungrouped bucket (no menu shown). */ actions?: { rename: () => void; delete: () => void } | undefined + /** Present only for real Workspace rows in the grouped view. */ + drag?: WorkspaceRowDragProps | undefined t: RowTranslate }) { const row = group // The ungrouped bucket has no workspace title: its label is dictionary copy. const label = row.workspaceId === undefined ? t('group.ungrouped') : row.label const active = group.expanded && group.containsCurrent - const count = t(row.sessionCount === 1 ? 'sessions.count.one' : 'sessions.count.other', { n: row.sessionCount }) const [menuOpen, setMenuOpen] = useState(false) const workspaceMenuItems = [ { id: 'rename', label: t('rename'), icon: }, @@ -101,6 +132,15 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: { role="treeitem" aria-expanded={row.expanded} onClick={onToggle} + draggable={drag !== undefined} + onDragStart={drag === undefined + ? undefined + : (e) => { + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', row.key) + drag.start() + }} + onDragEnd={drag?.end} > {row.expanded ? : } @@ -110,7 +150,6 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: { {label} - {count} {actions !== undefined && ( @@ -220,6 +259,18 @@ function sessionStatuses( return [{ state: 'done', label: t('status.idle') }] } +/** Primary status dot plus every status's screen-reader label, shared by the search and session rows. */ +function SessionStatusDots({ statuses }: { statuses: readonly [SessionStatus, ...SessionStatus[]] }) { + return ( + <> + + {statuses.map(status => ( + {status.label} + ))} + + ) +} + /** Hover-card body: full title, relative time, and every relevant live status. */ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) { const statuses = sessionStatuses(node, t) @@ -239,24 +290,6 @@ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; ) } -/** - * Session-row drag wiring supplied by the group owner (workspace groups only). - * `drop` reports the half of the row the pointer released on: 'before' - * inserts above this row, 'after' below it (the owner resolves the anchor). - */ -export interface RowDragProps { - /** Start dragging this row. */ - start: () => void - /** A drag from the same group is in flight (rows show insert markers). */ - active: boolean - /** Current marker on this row: insert line above, below, or none. */ - marker: 'before' | 'after' | null - /** Report the hovered half while a same-group drag passes over this row. */ - hover: (half: 'before' | 'after') => void - drop: (half: 'before' | 'after') => void - end: () => void -} - /** * One flat search result: title, Workspace context, and optional content * excerpt. Search navigation opens the session only; it does not address an @@ -287,30 +320,21 @@ export function SearchResultItem({ result, currentId, onOpen, t }: { {(primaryStatus.state !== 'done' || result.completed) && ( - <> - - {statuses.map(status => ( - {status.label} - ))} - + )} {result.title} - {result.workspace} - {result.snippet !== undefined && ( - {result.snippet} - )} + + {result.workspace} + {result.snippet !== undefined && ( + {result.snippet} + )} + ) } -/** Pointer-position half of a row (insert line above or below). */ -function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' { - const rect = e.currentTarget.getBoundingClientRect() - return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' -} - /** * One top-level 34px session row: status dot (pending user interaction outranks * own or descendant activity), title, relative time, and the row actions menu. @@ -322,10 +346,11 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | * @param props.onFork - fork a session at its last completed turn. * @param props.onArchive - archive a session by id. * @param props.drag - optional draggable-row wiring. + * @param props.flat - omit the empty status slot in the hierarchy-free flat list. * @param props.t - the browser root's locale seat. * @returns the session row. */ -export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, drag, t }: { +export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, drag, flat = false, t }: { node: SessionNode currentId: string | undefined now: number @@ -338,6 +363,8 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork onArchive: (id: SessionNode['id']) => void /** Present only on draggable rows (workspace-group sessions outside search). */ drag?: RowDragProps | undefined + /** The row is rendered without a parent Workspace header. */ + flat?: boolean | undefined t: RowTranslate }) { const row = node @@ -345,6 +372,7 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork const selected = node.id === currentId const statuses = sessionStatuses(node, t) const primaryStatus = statuses[0] + const showStatus = primaryStatus.state !== 'done' || row.completed const [menuOpen, setMenuOpen] = useState(false) // Archive hides the row through the registry-global archive set and never // touches the session log, so it is not styled as destructive and needs no @@ -360,6 +388,7 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
    { e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', node.id) drag.start() }} onDragEnd={drag?.end} @@ -392,16 +422,11 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork {/* Pending interaction and own or descendant activity outrank the finished-but-unviewed reminder, which returns after activity stops and is cleared by opening the session. */} - - {(primaryStatus.state !== 'done' || row.completed) && ( - <> - - {statuses.map(status => ( - {status.label} - ))} - - )} - + {(!flat || showStatus) && ( + + {showStatus && } + + )} {title} {/* A blank New Session row is a provisional placeholder: nothing has happened in it yet, so a "now" timestamp and the row verbs diff --git a/packages/client/ui-workspace/src/client/stores.ts b/packages/client/ui-workspace/src/client/stores.ts index ed89d80d9e..4df6fd6fc3 100644 --- a/packages/client/ui-workspace/src/client/stores.ts +++ b/packages/client/ui-workspace/src/client/stores.ts @@ -7,11 +7,25 @@ */ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' +/** Browser-local order account for the hierarchy-free flat Session list. */ +export const FLAT_SESSION_ORDER_KEY = '__flat_session_order__' + /** Session-list grouping mode: workspace sections or one flat recency list. */ export type WorkspaceGroupBy = 'workspace' | 'flat' +/** Session order: user-arranged only, or user-arranged plus activity promotion. */ +export type WorkspaceOrderBy = 'manual' | 'updated' -/** Workspace browser viewing state (grouping mode only; transient UI facts stay component-local). */ -type WorkspaceViewState = { groupBy: WorkspaceGroupBy } +/** Workspace browser viewing state persisted across surface remounts and reloads. */ +type WorkspaceViewState = { + groupBy: WorkspaceGroupBy + orderBy: WorkspaceOrderBy + /** Explicit zero-or-five-session state keyed by Workspace group identity. */ + workspaceExpansion: Record + /** Shared editable order per Workspace group plus the browser-local flat-list account. */ + recentSessionOrder: Record + /** Last observed update timestamps per order account for one-time promotion events. */ + recentSessionUpdatedAt: Record> +} /** * Annotation twin of the actions literal below (the export needs a declared @@ -19,6 +33,16 @@ type WorkspaceViewState = { groupBy: WorkspaceGroupBy } */ type WorkspaceViewActions = { setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void + setOrderBy: (draft: WorkspaceViewState, mode: WorkspaceOrderBy) => void + setWorkspaceExpanded: (draft: WorkspaceViewState, key: string, expanded: boolean) => void + retainWorkspaceKeys: (draft: WorkspaceViewState, workspaceKeys: readonly string[]) => void + syncRecentSessions: ( + draft: WorkspaceViewState, + workspaceKey: string, + order: string[], + updatedAt: Record, + ) => void + setRecentSessionOrder: (draft: WorkspaceViewState, workspaceKey: string, order: string[]) => void } /** @@ -27,10 +51,37 @@ type WorkspaceViewActions = { */ export function createWorkspaceViewStore(): EngineStoreHandle { return defineStore({ - init: (): WorkspaceViewState => ({ groupBy: 'workspace' }), - persist: 'dsh.workspace.view', + init: (): WorkspaceViewState => ({ + groupBy: 'workspace', + orderBy: 'manual', + workspaceExpansion: {}, + recentSessionOrder: {}, + recentSessionUpdatedAt: {}, + }), + persist: 'dsh.workspace.view.v4', actions: { setGroupBy: (d, mode: WorkspaceGroupBy) => { d.groupBy = mode }, + setOrderBy: (d, mode: WorkspaceOrderBy) => { d.orderBy = mode }, + setWorkspaceExpanded: (d, key: string, expanded: boolean) => { d.workspaceExpansion[key] = expanded }, + retainWorkspaceKeys: (d, workspaceKeys: readonly string[]) => { + const retained = new Set(workspaceKeys) + d.workspaceExpansion = Object.fromEntries( + Object.entries(d.workspaceExpansion).filter(([key]) => retained.has(key)), + ) + d.recentSessionOrder = Object.fromEntries( + Object.entries(d.recentSessionOrder).filter(([key]) => retained.has(key)), + ) + d.recentSessionUpdatedAt = Object.fromEntries( + Object.entries(d.recentSessionUpdatedAt).filter(([key]) => retained.has(key)), + ) + }, + syncRecentSessions: (d, workspaceKey: string, order: string[], updatedAt: Record) => { + d.recentSessionOrder[workspaceKey] = order + d.recentSessionUpdatedAt[workspaceKey] = updatedAt + }, + setRecentSessionOrder: (d, workspaceKey: string, order: string[]) => { + d.recentSessionOrder[workspaceKey] = order + }, }, }) } diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 008ab687f7..e9cbf8ed71 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -32,6 +32,9 @@ export interface SessionNode { updatedAt: number } +/** Session order selected by the Workspace browser. */ +export type SessionOrderBy = 'manual' | 'updated' + /** One workspace group section: header row facts + visible top-level session rows. */ export interface GroupNode { /** Group key: the workspace id or {@link UNGROUPED_KEY}. */ @@ -75,6 +78,8 @@ export interface SearchResultSet { /** Viewing state consumed by the derivation. */ export interface TreeView { expandedProjects: readonly string[] + /** Browser-local order for Sessions without a backing Workspace account. */ + ungroupedOrder?: readonly string[] } interface Group { @@ -136,21 +141,41 @@ function buildGroup( order: 'account' | 'recency', ): Group { const sessions = [...members] - // Workspace order is workspace.sessionIds; only Ungrouped lacks an account - // order and therefore falls back to recency. + // Real Workspace order comes from sessionIds. Ungrouped falls back to + // recency until the browser supplies its persisted local order. if (order === 'recency') sessions.sort(byRecency) return { key, workspaceId, cwd, createdAt, label, sessions } } +/** Apply a stored Ungrouped order and append newly loose Sessions by recency. */ +function orderedUngrouped(members: readonly SessionSummary[], stored: readonly string[]): SessionSummary[] { + const byId = new Map(members.map(session => [session.id as string, session])) + const included = new Set() + const ordered: SessionSummary[] = [] + for (const key of stored) { + const session = byId.get(key) + if (session === undefined || included.has(key)) continue + ordered.push(session) + included.add(key) + } + for (const session of [...members].sort(byRecency)) { + if (included.has(session.id)) continue + ordered.push(session) + } + return ordered +} + /** * Group Sessions by Host Workspace: one group per entity in stable Host * order, with members resolved from sessionIds in their stored order. Sessions - * outside every Workspace trail in the recency-ordered Ungrouped bucket. + * outside every Workspace trail in the browser-local Ungrouped order, which + * falls back to recency before that order is initialized. */ function groupByWorkspace( list: SessionListState, workspaces: readonly WorkspaceView[], archived: ReadonlySet, + ungroupedOrder: readonly string[] | undefined, ): Group[] { const groups: Group[] = [] const accounted = new Set() @@ -173,7 +198,15 @@ function groupByWorkspace( .filter((s): s is SessionSummary => s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current, archived)) if (stray.length > 0) { - groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, undefined, UNGROUPED_LABEL, stray, 'recency')) + groups.push(buildGroup( + UNGROUPED_KEY, + undefined, + undefined, + undefined, + UNGROUPED_LABEL, + ungroupedOrder === undefined ? stray : orderedUngrouped(stray, ungroupedOrder), + ungroupedOrder === undefined ? 'recency' : 'account', + )) } return groups } @@ -197,8 +230,8 @@ function sessionNode( /** * Derive the workspace browser groups with every session as a top-level row. * - * Every group shows; sessions populate under expanded groups, preserving - * Host account order. Blank sessions are excluded except for the selected + * Every group shows; sessions populate under expanded groups in the selected + * local order. Blank sessions are excluded except for the selected * provisional New Session row; archived sessions are excluded everywhere. * Content search lives outside this derivation * (see {@link deriveSearchResults}). @@ -222,7 +255,7 @@ export function deriveGroups( : (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined) ?? UNGROUPED_KEY const groups: GroupNode[] = [] - for (const g of groupByWorkspace(list, workspaces, archived)) { + for (const g of groupByWorkspace(list, workspaces, archived, view.ungroupedOrder)) { const expanded = expandedProjects.has(g.key) groups.push({ key: g.key, @@ -248,7 +281,10 @@ export function deriveGroups( * @param archivedSessionIds - registry-global archive set. * @returns flat rows in render order. */ -export function deriveFlat(list: SessionListState, archivedSessionIds: readonly SessionId[]): SessionNode[] { +export function deriveFlat( + list: SessionListState, + archivedSessionIds: readonly SessionId[], +): SessionNode[] { const archived = new Set(archivedSessionIds) const descendants = indexSubagentDescendants(list.byId) const rows: SessionSummary[] = [] diff --git a/packages/client/ui-workspace/tests/browser-styles.client.spec.ts b/packages/client/ui-workspace/tests/browser-styles.client.spec.ts index 4165971bff..d66baef917 100644 --- a/packages/client/ui-workspace/tests/browser-styles.client.spec.ts +++ b/packages/client/ui-workspace/tests/browser-styles.client.spec.ts @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' const css = readFileSync(fileURLToPath(new URL('../src/client/WorkspaceBrowser.module.css', import.meta.url)), 'utf8') +const rowsCss = readFileSync(fileURLToPath(new URL('../src/client/rows/Rows.module.css', import.meta.url)), 'utf8') /** * Declarations of one selector rule, keyed by property with whitespace collapsed. @@ -15,21 +16,23 @@ const css = readFileSync(fileURLToPath(new URL('../src/client/WorkspaceBrowser.m * @param selector - one exact selector, including a leading dot for local classes. * @returns the rule's declarations, or undefined when no such rule exists. */ -function declarations(selector: string): Map | undefined { - const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ' ') +function declarationsFrom(source: string, selector: string): Map | undefined { + const withoutComments = source.replace(/\/\*[\s\S]*?\*\//g, ' ') + const found = new Map() for (const [, selectorList = '', body = ''] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { if (!selectorList.split(',').map(value => value.trim()).includes(selector)) continue - const found = new Map() for (const part of body.split(';')) { const colon = part.indexOf(':') if (colon === -1) continue found.set(part.slice(0, colon).trim(), part.slice(colon + 1).trim().replace(/\s+/g, ' ')) } - return found } - return undefined + return found.size === 0 ? undefined : found } +const declarations = (selector: string): Map | undefined => declarationsFrom(css, selector) +const rowDeclarations = (selector: string): Map | undefined => declarationsFrom(rowsCss, selector) + describe('WorkspaceBrowser.module.css list', () => { const root = declarations('.root') const listArea = declarations('.listArea') @@ -45,9 +48,13 @@ describe('WorkspaceBrowser.module.css list', () => { expect(root?.get('--dsh-session-list-scrollbar-width')).toBe('8px') expect(root?.get('--dsh-session-list-scrollbar-offset')).toBe('2px') expect(root?.get('padding-right')).toBe('var(--dsh-session-list-edge-inset)') + expect(listArea?.get('margin-left')).toBe('-4px') + expect(listArea?.get('padding-left')).toBe('4px') expect(listArea?.get('margin-right')).toBe('calc(-1 * var(--dsh-session-list-edge-inset))') expect(declarations('.fade')?.get('right')).toBe('var(--dsh-session-list-edge-inset)') expect(list?.get('margin-right')).toBe('var(--dsh-session-list-scrollbar-offset)') + expect(list?.get('margin-left')).toBe('-4px') + expect(list?.get('padding-left')).toBe('4px') expect(list?.get('padding-right')).toBe([ 'calc(', 'var(--dsh-session-list-edge-inset)', @@ -68,4 +75,36 @@ describe('WorkspaceBrowser.module.css list', () => { expect(declarations('.groupSection > * + *')?.get('margin-top')).toBe('2px') expect(declarations('.groupSection + .groupSection')?.get('margin-top')).toBe('4px') }) + + it('draws drag targets as a leading chevron joined to the insertion line', () => { + const listTopMarker = declarations('.listTopDropIndicator') + const workspaceMarker = declarations('.workspaceDropBefore::before') + const sessionMarker = rowDeclarations('.sessionRow.dropBefore::before') + expect(listTopMarker?.get('top')).toBe('-8px') + expect(listTopMarker?.get('left')).toBe('0') + expect(workspaceMarker?.get('left')).toBe('0') + expect(sessionMarker?.get('left')).toBe('0') + for (const marker of [listTopMarker, workspaceMarker, sessionMarker]) { + expect(marker?.get('height')).toBe('12px') + expect(marker?.get('background')).not.toContain('radial-gradient') + expect(marker?.get('background')).toContain('55deg') + expect(marker?.get('background')).toContain('125deg') + expect(marker?.get('background')).toContain('calc(50% - 1px) calc(50% + 1px)') + expect(marker?.get('background')).toContain('0 0 / 5px 7px') + expect(marker?.get('background')).toContain('0 5px / 5px 7px') + expect(marker?.get('background')).toContain('4px 5px / calc(100% - 4px) 2px') + } + }) + + it('keeps the compact fade, overflow control, search field, and row heights', () => { + expect(declarations('.fade')?.get('height')).toBe('24px') + expect(declarations('.sessionOverflowButton')?.get('height')).toBe('28px') + expect(declarations('.searchExpanded')?.get('height')).toBe('30px') + expect(rowDeclarations('.projectRow')?.get('height')).toBe('34px') + expect(rowDeclarations('.sessionRow')?.get('height')).toBe('32px') + expect(rowDeclarations('.flatSessionRowWithoutStatus .title')?.get('margin-left')).toBe('0') + expect(rowDeclarations('.searchResultRow')?.get('min-height')).toBe('48px') + expect(rowDeclarations('.sessionRow.selected')?.get('background')) + .toBe('var(--dsw-alias-interactive-bg-hover)') + }) }) diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index 7e0971cf72..c7a153ff5f 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -46,7 +46,7 @@ function installClipboard(writeText: (text: string) => Promise): () => voi } } -const dataTransfer = { effectAllowed: '', dropEffect: '' } +const dataTransfer = { effectAllowed: '', dropEffect: '', setData: vi.fn() } /** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */ function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void { @@ -57,6 +57,21 @@ function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): } describe('workspace browser rows', () => { + it('omits only an empty leading status slot in the hierarchy-free flat list', () => { + const idle: SessionNode = { + id: sid('flat'), title: 'Flat Session', blank: false, running: false, + runningSubagentCount: 0, completed: false, updatedAt: 0, + } + const view = render() + const title = screen.getByText('Flat Session') + expect(title.previousElementSibling).toBeNull() + + view.rerender() + expect(screen.getByText('Flat Session').previousElementSibling?.querySelector('[data-state="ongoing"]')).toBeTruthy() + }) + it('renders a selected content-search row and opens only its session', () => { const onOpen = vi.fn() const result: SearchResultNode = { @@ -105,7 +120,6 @@ describe('workspace browser rows', () => { } render() - expect(screen.getByText('1 个会话')).toBeTruthy() expect(screen.getByRole('treeitem').getAttribute('aria-expanded')).toBe('true') fireEvent.click(screen.getByRole('button', { name: '在“Project”中新建会话' })) expect(onCreate).toHaveBeenCalledOnce() diff --git a/packages/client/ui-workspace/tests/tree.client.spec.ts b/packages/client/ui-workspace/tests/tree.client.spec.ts index 5a6d145e36..3ad1468af5 100644 --- a/packages/client/ui-workspace/tests/tree.client.spec.ts +++ b/packages/client/ui-workspace/tests/tree.client.spec.ts @@ -11,7 +11,8 @@ import { createWorkspaceViewStore } from '../src/client/stores.ts' const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId const summary = (id: string, updatedAt: number, cwd?: string): SessionSummary => ({ - id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...(cwd === undefined ? {} : { cwd }), + id: sid(id), displayTitle: id, running: false, blank: false, + updatedAt, ...(cwd === undefined ? {} : { cwd }), }) const list = (...items: SessionSummary[]): SessionListState => ({ ids: items.map(item => item.id), @@ -23,8 +24,9 @@ const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView workspaceId: wid(id), path: `/projects/${id}`, title, sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', }) -const view = (expandedProjects: readonly string[] = []) => ({ +const view = (expandedProjects: readonly string[] = [], ungroupedOrder?: readonly string[]) => ({ expandedProjects, + ...(ungroupedOrder === undefined ? {} : { ungroupedOrder }), }) const noArchive: readonly SessionId[] = [] const archived = (...ids: string[]): readonly SessionId[] => ids.map(sid) @@ -53,6 +55,19 @@ describe('deriveGroups', () => { expect(groups[1]!.sessions.map(session => session.id)).toEqual([sid('loose')]) }) + it('applies stored Ungrouped order and appends new loose Sessions by recency', () => { + const sessions = list(summary('one', 3), summary('two', 2), summary('new', 4)) + const groups = deriveGroups( + sessions, + [], + noArchive, + view([UNGROUPED_KEY], ['two', 'stale', 'two']), + ) + expect(groups[0]!.sessions.map(session => session.id)).toEqual([ + sid('two'), sid('new'), sid('one'), + ]) + }) + it('shows only the current blank session in its Workspace count and tree', () => { const currentBlank = { ...summary('current-blank', 5), blank: true } const staleBlank = { ...summary('stale-blank', 4), blank: true } @@ -377,11 +392,38 @@ describe('deriveSearchResults', () => { }) describe('createWorkspaceViewStore', () => { - it('defaults to workspace grouping; setGroupBy is the sole mutation', () => { + it('stores grouping, ordering, Workspace expansion, and recent-session view order', () => { const store = createWorkspaceViewStore().create() expect(store.getSnapshot().groupBy).toBe('workspace') + expect(store.getSnapshot().orderBy).toBe('manual') store.actions.setGroupBy('flat') + store.actions.setOrderBy('updated') + store.actions.setWorkspaceExpanded('alpha', true) + store.actions.syncRecentSessions('alpha', ['two', 'one'], { one: 1, two: 2 }) + store.actions.setRecentSessionOrder('alpha', ['one', 'two']) expect(store.getSnapshot().groupBy).toBe('flat') + expect(store.getSnapshot()).toMatchObject({ + orderBy: 'updated', + workspaceExpansion: { alpha: true }, + recentSessionOrder: { alpha: ['one', 'two'] }, + recentSessionUpdatedAt: { alpha: { one: 1, two: 2 } }, + }) + }) + + it('removes view state outside the retained Workspace key set', () => { + const store = createWorkspaceViewStore().create() + store.actions.setWorkspaceExpanded('', true) + store.actions.setWorkspaceExpanded('alpha', true) + store.actions.setWorkspaceExpanded('deleted', true) + store.actions.syncRecentSessions('alpha', ['alpha-session'], { 'alpha-session': 2 }) + store.actions.syncRecentSessions('deleted', ['deleted-session'], { 'deleted-session': 1 }) + + store.actions.retainWorkspaceKeys(['', 'alpha']) + + const snapshot = store.getSnapshot() + expect(snapshot.workspaceExpansion).toEqual({ '': true, alpha: true }) + expect(snapshot.recentSessionOrder).toEqual({ alpha: ['alpha-session'] }) + expect(snapshot.recentSessionUpdatedAt).toEqual({ alpha: { 'alpha-session': 2 } }) }) }) diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 918a3f6c0e..c39e7c37c4 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -8,7 +8,8 @@ import type { import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { WorkspaceBrowserProps } from '../src/client/contract/slots.ts' -import { createWorkspaceViewStore } from '../src/client/stores.ts' +import { createWorkspaceViewStore, FLAT_SESSION_ORDER_KEY } from '../src/client/stores.ts' +import { UNGROUPED_KEY } from '../src/client/tree.ts' import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx' import { zh } from '../src/client/locales.ts' @@ -53,6 +54,10 @@ function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): fireEvent(row, event) } +function dragData(): Pick { + return { effectAllowed: 'uninitialized', dropEffect: 'none', setData: vi.fn() } +} + function mount(overrides: Partial = {}) { const store = createWorkspaceViewStore().create() const props: WorkspaceBrowserProps = { @@ -71,6 +76,7 @@ function mount(overrides: Partial = {}) { renameWorkspace: vi.fn(async () => {}), deleteWorkspace: vi.fn(async () => {}), archiveSession: vi.fn(async () => {}), + insertWorkspaceBefore: vi.fn(async () => {}), insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }), @@ -89,6 +95,28 @@ function rerender(b: ReturnType, overrides: Partial { + it('prunes deleted Workspace view state only after the Workspace baseline is ready', async () => { + const pending = { + ...workspaceState([]), + phase: 'pending' as const, + state: 'loading' as const, + baselinesReady: false, + } + const b = mount({ useWorkspaces: hook(pending) }) + act(() => { + b.store.actions.setWorkspaceExpanded('deleted', true) + b.store.actions.syncRecentSessions('deleted', ['session'], { session: 1 }) + }) + expect(b.store.getSnapshot().workspaceExpansion).toEqual({ deleted: true }) + + rerender(b, { useWorkspaces: hook(workspaceState([])) }) + await waitFor(() => { + expect(b.store.getSnapshot().workspaceExpansion).toEqual({}) + expect(b.store.getSnapshot().recentSessionOrder).toEqual({ [UNGROUPED_KEY]: [] }) + expect(b.store.getSnapshot().recentSessionUpdatedAt).toEqual({ [UNGROUPED_KEY]: {} }) + }) + }) + it('renders the grouped tree by default and switches to the flat list via Group by', () => { const sessions = sessionState([summary('alpha-s', 2), summary('beta-s', 1)]) const b = mount({ @@ -100,8 +128,14 @@ describe('WorkspaceBrowser', () => { // Sessions hidden while their group is folded. expect(screen.queryByText('alpha-s')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: '分组方式' })) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) expect(screen.getByText('分组方式')).toBeTruthy() // the menu heading label + expect(screen.getByRole('separator')).toBeTruthy() + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([ + '按工作区', '单列表', '手动排序', '最近更新', + ]) + expect(screen.getByRole('menuitem', { name: '按工作区' }).querySelector('svg')).toBeTruthy() + expect(screen.getByRole('menuitem', { name: '手动排序' }).querySelector('svg')).toBeTruthy() fireEvent.click(screen.getByRole('menuitem', { name: '单列表' })) // Store-driven flip: title changes, rows flatten newest-first, headers gone. expect(b.store.getSnapshot().groupBy).toBe('flat') @@ -111,18 +145,73 @@ describe('WorkspaceBrowser', () => { expect(screen.getByText('beta-s')).toBeTruthy() // Back to workspace grouping through the same menu. - fireEvent.click(screen.getByRole('button', { name: '分组方式' })) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + expect(screen.getByRole('menuitem', { name: '手动排序' }).hasAttribute('disabled')).toBe(false) fireEvent.click(screen.getByRole('menuitem', { name: '按工作区' })) expect(b.store.getSnapshot().groupBy).toBe('workspace') expect(screen.getByText('工作区')).toBeTruthy() // Escape closes the menu without picking. - fireEvent.click(screen.getByRole('button', { name: '分组方式' })) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) fireEvent.keyDown(document, { key: 'Escape' }) expect(screen.queryByRole('menu')).toBeNull() expect(b.store.getSnapshot().groupBy).toBe('workspace') }) + it('persists flat-list drag order locally and applies Last updated within that account', async () => { + const insertSessionBefore = vi.fn(async () => {}) + const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)]) + const workspaces = workspaceState([ + workspace('alpha', ['one']), + workspace('beta', ['two']), + ]) + const b = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaces), + insertSessionBefore, + }) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '单列表' })) + await waitFor(() => { + expect(b.store.getSnapshot().recentSessionOrder[FLAT_SESSION_ORDER_KEY]) + .toEqual(['one', 'two', 'three']) + }) + + const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement + const three = screen.getByText('three').closest('[role="treeitem"]') as HTMLElement + three.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, + x: 0, y: 150, toJSON: () => ({}), + }) + fireEvent.dragStart(one, { dataTransfer: dragData() }) + fireDrag(three, 'drop', 180) + expect(b.store.getSnapshot().recentSessionOrder[FLAT_SESSION_ORDER_KEY]) + .toEqual(['two', 'three', 'one']) + expect(insertSessionBefore).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' })) + await waitFor(() => { + expect(b.store.getSnapshot().recentSessionOrder[FLAT_SESSION_ORDER_KEY]) + .toEqual(['one', 'two', 'three']) + }) + + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '手动排序' })) + fireEvent.dragStart(one, { dataTransfer: dragData() }) + fireDrag(three, 'drop', 180) + b.view.unmount() + + const restored = mount({ useSessions: hook(sessions), useWorkspaces: hook(workspaces) }) + expect(restored.store.getSnapshot().groupBy).toBe('flat') + expect(restored.store.getSnapshot().orderBy).toBe('manual') + expect(screen.getAllByRole('treeitem').map(row => row.textContent)).toEqual([ + expect.stringContaining('two'), + expect.stringContaining('three'), + expect.stringContaining('one'), + ]) + }) + it('expands a group on click and opens a session row', () => { const open = vi.fn() mount({ @@ -138,6 +227,93 @@ describe('WorkspaceBrowser', () => { expect(screen.queryByText('alpha-s')).toBeNull() }) + it('shows five sessions by default and clears transient show-all when the Workspace collapses', () => { + const items = Array.from({ length: 7 }, (_, index) => summary(`session-${index + 1}`, 7 - index)) + const b = mount({ + useSessions: hook(sessionState(items)), + useWorkspaces: hook(workspaceState([workspace('alpha', items.map(item => item.id))])), + }) + fireEvent.click(screen.getByText('alpha')) + for (const item of items.slice(0, 5)) expect(screen.getByText(item.displayTitle)).toBeTruthy() + expect(screen.queryByText('session-6')).toBeNull() + expect(screen.queryByText('session-7')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: '展开其余 2 个会话' })) + expect(screen.getByText('session-6')).toBeTruthy() + expect(screen.getByText('session-7')).toBeTruthy() + expect(screen.getByRole('button', { name: '收起' })).toBeTruthy() + + fireEvent.click(screen.getByText('alpha')) + expect(b.store.getSnapshot().workspaceExpansion).toEqual({ alpha: false }) + fireEvent.click(screen.getByText('alpha')) + expect(b.store.getSnapshot().workspaceExpansion).toEqual({ alpha: true }) + expect(screen.queryByText('session-6')).toBeNull() + expect(screen.getByRole('button', { name: '展开其余 2 个会话' })).toBeTruthy() + }) + + it('shares one editable order across modes and promotes only while Last updated is active', async () => { + const initial = sessionState([summary('one', 3), summary('two', 2)]) + const b = mount({ + useSessions: hook(initial), + useWorkspaces: hook(workspaceState([workspace('alpha', ['two', 'one'])])), + }) + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' })) + await waitFor(() => { + const rows = screen.getAllByRole('treeitem').slice(1) + expect(rows[0]?.textContent).toContain('one') + expect(rows[1]?.textContent).toContain('two') + }) + + const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement] + two.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), + }) + fireEvent.dragStart(one, { dataTransfer: dragData() }) + fireDrag(two, 'drop', 180) + expect(b.store.getSnapshot().recentSessionOrder.alpha).toEqual(['two', 'one']) + + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '手动排序' })) + expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two') + + // User activity updates the timestamp baseline in Manual mode without + // changing the shared visual order. + const updated = sessionState([summary('one', 4), summary('two', 2)]) + rerender(b, { useSessions: hook(updated) }) + await waitFor(() => { + expect(b.store.getSnapshot().recentSessionUpdatedAt.alpha).toEqual({ one: 4, two: 2 }) + }) + expect(b.store.getSnapshot().recentSessionOrder.alpha).toEqual(['two', 'one']) + expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two') + + // Entering Last updated performs one complete recency sort. + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' })) + await waitFor(() => { + expect(b.store.getSnapshot().recentSessionOrder.alpha).toEqual(['one', 'two']) + expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('one') + }) + + // A later user activity timestamp promotes that Session once while the + // mode remains active. + const promoted = sessionState([summary('one', 4), summary('two', 5)]) + rerender(b, { useSessions: hook(promoted) }) + await waitFor(() => { + expect(b.store.getSnapshot().recentSessionOrder.alpha).toEqual(['two', 'one']) + expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two') + }) + + b.view.unmount() + const restored = mount({ + useSessions: hook(promoted), + useWorkspaces: hook(workspaceState([workspace('alpha', ['two', 'one'])])), + }) + expect(restored.store.getSnapshot().recentSessionOrder.alpha).toEqual(['two', 'one']) + expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two') + }) + it('archives a session from the row menu and hides archived rows in both modes', async () => { const archiveSession = vi.fn(async () => {}) const b = mount({ @@ -150,11 +326,10 @@ describe('WorkspaceBrowser', () => { fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' })) expect(archiveSession).toHaveBeenCalledWith(sid('gone-s')) - // The archive-set echo hides the row in grouped mode (count included) and flat mode. + // The archive-set echo hides the row in grouped and flat modes. rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['kept-s', 'gone-s'])], [sid('gone-s')])) }) expect(screen.queryByText('gone-s')).toBeNull() - expect(screen.getByText('1 个会话')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: '分组方式' })) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) fireEvent.click(screen.getByRole('menuitem', { name: '单列表' })) expect(screen.getByText('kept-s')).toBeTruthy() expect(screen.queryByText('gone-s')).toBeNull() @@ -195,16 +370,20 @@ describe('WorkspaceBrowser', () => { expect(screen.getByText('child-s').closest('[role="treeitem"]')?.getAttribute('draggable')).toBe('true') }) - it('auto-expands the selected session group and starts a session from the group +', () => { + it('expands the target group before starting a session from its +', () => { const startSession = vi.fn() - mount({ - useSessions: hook(sessionState([summary('alpha-s', 1)], { current: sid('alpha-s') })), + const b = mount({ + useSessions: hook(sessionState([summary('alpha-s', 1)])), useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])), startSession, }) - // The current-group effect expanded the owning group without a click. - expect(screen.getByText('alpha-s')).toBeTruthy() + startSession.mockImplementation(() => { + expect(b.store.getSnapshot().workspaceExpansion).toEqual({ alpha: true }) + }) + expect(screen.queryByText('alpha-s')).toBeNull() fireEvent.click(screen.getByRole('button', { name: '在“alpha”中新建会话' })) + expect(b.store.getSnapshot().workspaceExpansion).toEqual({ alpha: true }) + expect(screen.getByText('alpha-s')).toBeTruthy() expect(startSession).toHaveBeenCalledWith(wid('alpha')) }) @@ -253,7 +432,6 @@ describe('WorkspaceBrowser', () => { expect(screen.getByText('新会话')).toBeTruthy() expect(screen.queryByText('alpha-blank')).toBeNull() expect(screen.queryByText('beta-blank')).toBeNull() - expect(screen.getByText('1 个会话')).toBeTruthy() rerender(b, { useSessions: hook({ ...sessions, current: staleBlank.id }) }) expect(screen.getAllByText('新会话')).toHaveLength(1) @@ -262,9 +440,9 @@ describe('WorkspaceBrowser', () => { expect(screen.getAllByText('新会话')).toHaveLength(1) // Search excludes blank rows entirely — neither the canonical stored // title nor the localized display label participates in matching. - fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'new session' } }) + fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'new session' } }) expect(screen.queryByText('新会话')).toBeNull() - fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: '新会话' } }) + fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: '新会话' } }) expect(screen.queryByText('新会话')).toBeNull() }) @@ -279,7 +457,8 @@ describe('WorkspaceBrowser', () => { useSessions: hook(sessions), useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])), }) - const input = screen.getByPlaceholderText('搜索名称、关键词…') + fireEvent.click(screen.getByRole('button', { name: '搜索会话' })) + const input = screen.getByPlaceholderText('搜索会话…') fireEvent.change(input, { target: { value: 'needle' } }) const resultTree = screen.getByRole('tree', { name: '搜索结果' }) expect(screen.getByText('Needle row')).toBeTruthy() @@ -302,6 +481,27 @@ describe('WorkspaceBrowser', () => { } }) + it('collapses an empty search on outside click but keeps a non-empty query expanded', () => { + mount() + const search = screen.getByRole('button', { name: '搜索会话' }) + fireEvent.click(search) + expect(search.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(document.body) + expect(search.getAttribute('aria-expanded')).toBe('false') + + fireEvent.click(search) + const input = screen.getByPlaceholderText('搜索会话…') + fireEvent.change(input, { target: { value: ' ' } }) + fireEvent.click(document.body) + expect(search.getAttribute('aria-expanded')).toBe('false') + + fireEvent.click(search) + fireEvent.change(input, { target: { value: 'kept' } }) + fireEvent.click(document.body) + expect(search.getAttribute('aria-expanded')).toBe('true') + expect(input.value).toBe('kept') + }) + it('adds Host content hits with context, shows the result bound, and opens without clearing the query', async () => { vi.useFakeTimers() try { @@ -320,7 +520,7 @@ describe('WorkspaceBrowser', () => { open, searchSessions, }) - const input = screen.getByPlaceholderText('搜索名称、关键词…') + const input = screen.getByPlaceholderText('搜索会话…') fireEvent.change(input, { target: { value: 'waterfall token' } }) expect(screen.getByText('正在搜索会话历史…')).toBeTruthy() expect(screen.queryByText('Research notes')).toBeNull() @@ -345,7 +545,7 @@ describe('WorkspaceBrowser', () => { try { const searchSessions = vi.fn(async () => ({ items: [], hasMore: false })) mount({ searchSessions }) - const input = screen.getByPlaceholderText('搜索名称、关键词…') + const input = screen.getByPlaceholderText('搜索会话…') expect(input.maxLength).toBe(500) fireEvent.change(input, { target: { value: 'y'.repeat(501) } }) expect(input.value).toBe('y'.repeat(500)) @@ -376,7 +576,7 @@ describe('WorkspaceBrowser', () => { useWorkspaces: hook(workspaceState([workspace('alpha', ['local-hit'])])), searchSessions, }) - fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { + fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'needle' }, }) expect(screen.getByText('Needle title')).toBeTruthy() @@ -413,7 +613,7 @@ describe('WorkspaceBrowser', () => { ])), searchSessions, }) - const input = screen.getByPlaceholderText('搜索名称、关键词…') + const input = screen.getByPlaceholderText('搜索会话…') fireEvent.change(input, { target: { value: 'first' } }) await act(async () => { await vi.advanceTimersByTimeAsync(250) }) const firstSignal = searchSessions.mock.calls[0]?.[1] as AbortSignal @@ -447,7 +647,7 @@ describe('WorkspaceBrowser', () => { ? first : Promise.resolve({ items: [], hasMore: false })) mount({ searchSessions }) - const input = screen.getByPlaceholderText('搜索名称、关键词…') + const input = screen.getByPlaceholderText('搜索会话…') fireEvent.change(input, { target: { value: 'first' } }) await act(async () => { await vi.advanceTimersByTimeAsync(250) }) @@ -470,7 +670,7 @@ describe('WorkspaceBrowser', () => { b.store.actions.setGroupBy('flat') rerender(b, {}) expect(screen.getByText('暂无会话')).toBeTruthy() - fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'x' } }) + fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'x' } }) expect(screen.getByText('正在搜索会话历史…')).toBeTruthy() await act(async () => { await vi.advanceTimersByTimeAsync(250) }) expect(screen.getByText('无匹配会话')).toBeTruthy() @@ -486,12 +686,12 @@ describe('WorkspaceBrowser', () => { const b = mount({ wide: false, expandSidebar }) // No wide chrome in rail state. expect(screen.queryByText('工作区')).toBeNull() - expect(screen.queryByPlaceholderText('搜索名称、关键词…')).toBeNull() + expect(screen.queryByPlaceholderText('搜索会话…')).toBeNull() fireEvent.click(screen.getByRole('button', { name: '搜索会话' })) expect(expandSidebar).toHaveBeenCalledTimes(1) // The wide flip mounts the input and focuses it after the slide. rerender(b, { wide: true }) - const input = screen.getByPlaceholderText('搜索名称、关键词…') + const input = screen.getByPlaceholderText('搜索会话…') act(() => { vi.advanceTimersByTime(300) }) expect(document.activeElement).toBe(input) // Wide search button is decorative (tabIndex -1, no expand call). @@ -524,6 +724,84 @@ describe('WorkspaceBrowser', () => { expect(screen.getByText('alpha')).toBeTruthy() }) + it('uses the full expanded Workspace section when resolving a Workspace drop half', () => { + const insertWorkspaceBefore = vi.fn(async () => {}) + const sessions = sessionState(Array.from({ length: 5 }, (_, index) => summary(`beta-${index}`, index))) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([ + workspace('alpha', []), + workspace('beta', sessions.ids), + workspace('tail', []), + ])), + insertWorkspaceBefore, + }) + fireEvent.click(screen.getByText('beta')) + const source = screen.getByText('tail').closest('[role="treeitem"]') as HTMLElement + let targetSection = screen.getByText('beta').closest('[role="treeitem"]')?.parentElement as HTMLElement + while (targetSection.parentElement?.getAttribute('role') !== 'tree') { + targetSection = targetSection.parentElement as HTMLElement + } + targetSection.getBoundingClientRect = () => ({ + top: 100, bottom: 300, left: 0, right: 200, width: 200, height: 200, x: 0, y: 100, toJSON: () => ({}), + }) + fireEvent.dragStart(source, { dataTransfer: dragData() }) + // y=190 is below the header row but still in the top half of the whole + // expanded section, so the target is before beta rather than after it. + fireDrag(targetSection, 'drop', 190) + expect(insertWorkspaceBefore).toHaveBeenCalledWith(wid('tail'), wid('beta')) + }) + + it('draws the first Workspace insertion boundary on the scroll container', () => { + mount({ + useWorkspaces: hook(workspaceState([ + workspace('alpha', []), + workspace('beta', []), + ])), + }) + const source = screen.getByText('beta').closest('[role="treeitem"]') as HTMLElement + let firstSection = screen.getByText('alpha').closest('[role="treeitem"]')?.parentElement as HTMLElement + while (firstSection.parentElement?.getAttribute('role') !== 'tree') { + firstSection = firstSection.parentElement as HTMLElement + } + firstSection.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}), + }) + fireEvent.dragStart(source, { dataTransfer: dragData() }) + fireDrag(firstSection, 'dragOver', 105) + expect(firstSection.parentElement?.className).toContain('listTopDropActive') + const marker = firstSection.parentElement?.previousElementSibling + expect(marker?.className).toContain('listTopDropIndicator') + }) + + it('accepts a document-level drop and commits the last Workspace marker on drag end', () => { + const insertWorkspaceBefore = vi.fn(async () => {}) + mount({ + useWorkspaces: hook(workspaceState([ + workspace('alpha', []), + workspace('beta', []), + workspace('tail', []), + ])), + insertWorkspaceBefore, + }) + const source = screen.getByText('tail').closest('[role="treeitem"]') as HTMLElement + let target = screen.getByText('beta').closest('[role="treeitem"]')?.parentElement as HTMLElement + while (target.parentElement?.getAttribute('role') !== 'tree') { + target = target.parentElement as HTMLElement + } + target.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}), + }) + fireEvent.dragStart(source, { dataTransfer: dragData() }) + fireDrag(target, 'dragOver', 105) + const outsideDrop = createEvent.drop(document.body) + Object.defineProperty(outsideDrop, 'dataTransfer', { value: dragData() }) + fireEvent(document.body, outsideDrop) + expect(outsideDrop.defaultPrevented).toBe(true) + fireEvent.dragEnd(source) + expect(insertWorkspaceBefore).toHaveBeenCalledWith(wid('tail'), wid('beta')) + }) + it('drag reorder reports the anchor to insertSessionBefore and skips no-op drops', () => { const insertSessionBefore = vi.fn(async () => {}) const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)]) @@ -538,7 +816,7 @@ describe('WorkspaceBrowser', () => { three.getBoundingClientRect = () => ({ top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34, x: 0, y: 200, toJSON: () => ({}), }) - const dataTransfer = { effectAllowed: '', dropEffect: '' } + const dataTransfer = dragData() fireEvent.dragStart(one, { dataTransfer }) // Drop on the top half of "three": insert one before three. fireDrag(three, 'dragOver', 205) @@ -559,6 +837,55 @@ describe('WorkspaceBrowser', () => { expect(insertSessionBefore).toHaveBeenCalledTimes(1) }) + it('persists Ungrouped drag order in both modes without writing a Host Workspace account', async () => { + const insertSessionBefore = vi.fn(async () => {}) + const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)]) + const b = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([])), + insertSessionBefore, + }) + fireEvent.click(screen.getByText('未分组')) + + const dragAfter = (sourceTitle: string, targetTitle: string): void => { + const source = screen.getByText(sourceTitle).closest('[role="treeitem"]') as HTMLElement + const target = screen.getByText(targetTitle).closest('[role="treeitem"]') as HTMLElement + target.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), + }) + fireEvent.dragStart(source, { dataTransfer: dragData() }) + fireDrag(target, 'drop', 180) + } + + dragAfter('one', 'three') + expect(b.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['two', 'three', 'one']) + dragAfter('two', 'one') + expect(b.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['three', 'one', 'two']) + expect(insertSessionBefore).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' })) + await waitFor(() => { + expect(b.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['one', 'two', 'three']) + }) + dragAfter('one', 'three') + expect(b.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['two', 'three', 'one']) + expect(insertSessionBefore).not.toHaveBeenCalled() + + b.view.unmount() + const restored = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([])), + insertSessionBefore, + }) + expect(restored.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['two', 'three', 'one']) + expect(screen.getAllByRole('treeitem').slice(1).map(row => row.textContent)).toEqual([ + expect.stringContaining('two'), + expect.stringContaining('three'), + expect.stringContaining('one'), + ]) + }) + it('still sends the reorder when the dragged row left the group mid-drag', () => { const insertSessionBefore = vi.fn(async () => {}) const sessions = sessionState([summary('one', 2), summary('two', 1)]) @@ -569,7 +896,7 @@ describe('WorkspaceBrowser', () => { }) fireEvent.click(screen.getByText('alpha')) const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement - fireEvent.dragStart(one, { dataTransfer: { effectAllowed: '', dropEffect: '' } }) + fireEvent.dragStart(one, { dataTransfer: dragData() }) // The host dropped "one" from the workspace account while the drag is in // flight: the source index is gone but the drop still resolves its anchor. rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['two'])])) }) @@ -594,7 +921,7 @@ describe('WorkspaceBrowser', () => { two.getBoundingClientRect = () => ({ top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), }) - const dataTransfer = { effectAllowed: '', dropEffect: '' } + const dataTransfer = dragData() fireEvent.dragStart(one, { dataTransfer }) fireEvent.dragEnd(one) // The drag ended: rows no longer accept drops. @@ -608,6 +935,28 @@ describe('WorkspaceBrowser', () => { expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), undefined) }) + it('accepts a document-level drop and commits the last Session marker on drag end', () => { + const insertSessionBefore = vi.fn(async () => {}) + mount({ + useSessions: hook(sessionState([summary('one', 2), summary('two', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])), + insertSessionBefore, + }) + fireEvent.click(screen.getByText('alpha')) + const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement] + two.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), + }) + fireEvent.dragStart(one, { dataTransfer: dragData() }) + fireDrag(two, 'dragOver', 180) + const outsideDrop = createEvent.drop(document.body) + Object.defineProperty(outsideDrop, 'dataTransfer', { value: dragData() }) + fireEvent(document.body, outsideDrop) + expect(outsideDrop.defaultPrevented).toBe(true) + fireEvent.dragEnd(one) + expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), undefined) + }) + it('logs and keeps the order when the reorder call rejects', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { @@ -623,7 +972,7 @@ describe('WorkspaceBrowser', () => { two.getBoundingClientRect = () => ({ top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), }) - const dataTransfer = { effectAllowed: '', dropEffect: '' } + const dataTransfer = dragData() fireEvent.dragStart(one, { dataTransfer }) fireDrag(two, 'drop', 180) await waitFor(() => { expect(warn).toHaveBeenCalledWith('session reorder rejected:', expect.any(Error)) }) @@ -778,7 +1127,7 @@ describe('WorkspaceBrowser', () => { useSessions: hook(sessions), useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-a'])])), }) - fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'needle' } }) + fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'needle' } }) const row = screen.getByText('Needle A').closest('[role="treeitem"]') as HTMLElement expect(row.hasAttribute('draggable')).toBe(false) }) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 1345dbcbcd..a0e3b413c1 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -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/host/apiproxy/README.md -README.md: 541ebdb7a6802286b9b698575136534486387a8c -README.zh.md: 595ef03c24873272fa78ad67289b264458083614 +README.md: 5915d20b176ed6eccdb2c939bdf58b0a122271c5 +README.zh.md: e1128323c45c8388562582de25cf8c68d936fac0 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 541ebdb7a6..5915d20b17 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -42,7 +42,7 @@ Pending queued input is a live control-plane contract, not conversation history. Background tasks ride the same live-push posture. When `ctx.tasks` is composed, the gateway subscribes to its change feed and broadcasts a whole `session/tasks` snapshot after every registry commit that alters what a session can see — registration, the stopping transition, settlement, and owner-disposal removal — plus a subscription baseline for each session that already has tasks (an absent baseline is the empty set; a change that empties a set still sends `[]`). A change carrying an owner reads through that exact `Agent`, so a push stays correct while its scope tears down; the baseline reads `ctx.agents.get(sessionId)`, which yields only unowned tasks for a session with no live Agent and never resumes a cold one. An unowned change fans out to every subscribed session, because unowned tasks are visible to every caller. The wire `TaskView` drops `ownerSession`, `reported`, and `outputLimitBytes`: the frame's own `sessionId` carries the first, and the other two are internal notice and model-presentation policy. A composition without the registry emits no such frames. -Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. +Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` commits one registry-order move and answers the complete order; a pure reorder emits `host/workspace-order-changed` with that complete order, while unknown sources or anchors return `workspace-not-found`. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. `session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 595ef03c24..e1128323c4 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -42,7 +42,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 后台任务沿用同一种实时推送姿态。当组合中有 `ctx.tasks` 时,网关订阅它的变更订阅,并在注册表每一次改变某个会话可见内容的提交后——注册、转入 stopping、结算,以及 owner 销毁时的移除——广播一份完整的 `session/tasks` 快照,另外为每个已经有任务的会话发送订阅 baseline(没有 baseline 即表示空集;把集合清空的那次变更仍然发送 `[]`)。带 owner 的变更通过那个确切的 `Agent` 读取,因此推送在其 scope 拆除期间依然正确;baseline 读 `ctx.agents.get(sessionId)`,对没有活体 Agent 的会话只得到无主任务,且绝不恢复冷会话。无主变更向每一个已订阅会话扇出,因为无主任务对所有调用方可见。线路上的 `TaskView` 丢弃 `ownerSession`、`reported` 和 `outputLimitBytes`:第一个由帧自身的 `sessionId` 携带,另外两个分别是内部通知位和模型呈现策略。没有该注册表的组合不发出这类帧。 -Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 +Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` 提交一次注册表顺序移动并应答完整顺序;单纯重排序会通过 `host/workspace-order-changed` 推送同一份完整顺序,而未知来源或锚点返回 `workspace-not-found`。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 `session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 5a22398aa9..c9567256db 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -25,7 +25,7 @@ import { isUserInvocable } from '@deepseek-ai/dsh-skill' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, - WorkspaceMoveInvalidError, WorkspaceUnknownSessionError, + WorkspaceMoveInvalidError, WorkspaceOrderInvalidError, WorkspaceUnknownSessionError, } from '@deepseek-ai/dsh-workspace' // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). import { @@ -2758,6 +2758,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return ok(request, { deleted: true as const }) }, + async insertBefore(request) { + const { workspaceId, beforeWorkspaceId } = request.payload + try { + const workspaceIds = await ctx.workspace.insertBefore( + brandWorkspaceId(workspaceId), + beforeWorkspaceId === undefined ? undefined : brandWorkspaceId(beforeWorkspaceId), + ) + return ok(request, { workspaceIds: [...workspaceIds] }) + } catch (error: unknown) { + if (!(error instanceof WorkspaceOrderInvalidError)) throw error + return workspaceNotFound(request, error.workspaceId) + } + }, + async insertSessionBefore(request) { const { payload } = request const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId)) @@ -3412,9 +3426,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro host(_request, signal) { const queue = new FrameQueue>() + const committedWorkspaces = ctx.workspace.list() const committedWorkspaceIds = new Set( - ctx.workspace.list().map(workspace => String(workspace.id)), + committedWorkspaces.map(workspace => String(workspace.id)), ) + let committedWorkspaceOrder = committedWorkspaces.map(workspace => workspace.id) // Frame-dedup baseline, same posture as committedWorkspaceIds: the // stream opens against the current set; workspace.list re-baselines // reconnecting clients, so only later changes need frames. @@ -3445,6 +3461,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (change.table === '') { if (change.operation !== 'put') return const state = workspaceDomainState.parse(change.value) + const orderChanged = state.workspaceIds.length === committedWorkspaceOrder.length + && state.workspaceIds.every(workspaceId => committedWorkspaceIds.has(String(workspaceId))) + && state.workspaceIds.some((workspaceId, index) => workspaceId !== committedWorkspaceOrder[index]) for (const workspaceId of state.workspaceIds) { if (committedWorkspaceIds.has(workspaceId)) continue const workspace = ctx.workspace.get(workspaceId) @@ -3454,6 +3473,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro committedWorkspaceIds.add(workspaceId) queue.push(frame({ type: 'host/workspace-changed', workspace: workspaceView(workspace) })) } + committedWorkspaceOrder = [...state.workspaceIds] + if (orderChanged) { + queue.push(frame({ + type: 'host/workspace-order-changed', + workspaceIds: [...state.workspaceIds], + })) + } if (state.archivedSessionIds.length !== archivedSessionIds.length || state.archivedSessionIds.some((id, index) => id !== archivedSessionIds[index])) { archivedSessionIds = state.archivedSessionIds diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index c8ddf99e8d..8b88186582 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -82,6 +82,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }), z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }), z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }), + z.object({ type: z.literal('host/workspace-order-changed'), workspaceIds: z.array(workspaceIdSchema) }), z.object({ type: z.literal('host/archived-sessions-changed'), archivedSessionIds: z.array(sessionIdSchema) }), // args stays wide, the same posture as session/projection's value: the frame // arrives from JSON.parse, so every element is already a JSON value, and the diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index 901379b181..beba99b595 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -119,7 +119,8 @@ export type MuxFrame = * workspace mutation (create/attach/order change — the client upserts, while * `workspace.list` provides the reconnect baseline); workspace-removed is the * committed registration-deletion increment and never implies directory or - * session-log deletion; archived-sessions-changed pushes the full registry + * session-log deletion; workspace-order-changed pushes the complete durable + * registry order after a reorder; archived-sessions-changed pushes the full registry * archive set after every durable change (same full-snapshot posture as * workspace-changed — `workspace.list` re-baselines it on reconnect). */ @@ -138,6 +139,7 @@ export type HostFrame = | { type: 'host/agent-error'; sessionId: SessionId; message: string } | { type: 'host/workspace-changed'; workspace: WorkspaceView } | { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] } + | { type: 'host/workspace-order-changed'; workspaceIds: WorkspaceView['workspaceId'][] } | { type: 'host/archived-sessions-changed'; archivedSessionIds: SessionId[] } /** * One allowlisted host cordis event forwarded verbatim. The allowlist is diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index f81ac842af..80dede1799 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -47,6 +47,7 @@ export interface RpcMethodMap { 'workspace.create': WorkspaceApi['create'] 'workspace.rename': WorkspaceApi['rename'] 'workspace.delete': WorkspaceApi['delete'] + 'workspace.insertBefore': WorkspaceApi['insertBefore'] 'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore'] 'workspace.archiveSession': WorkspaceApi['archiveSession'] 'skill.list': SkillsApi['list'] diff --git a/packages/host/apiproxy/src/api/workspace.schema.ts b/packages/host/apiproxy/src/api/workspace.schema.ts index 5ad5a0b96b..b57305141c 100644 --- a/packages/host/apiproxy/src/api/workspace.schema.ts +++ b/packages/host/apiproxy/src/api/workspace.schema.ts @@ -66,6 +66,17 @@ export const workspaceDeleteValueSchema = z.object({ deleted: z.literal(true), }) satisfies z.ZodType>> +/** workspace.insertBefore request payload (anchor omitted = append to end). */ +export const workspaceInsertBeforeRequestSchema = z.object({ + workspaceId: workspaceIdSchema, + beforeWorkspaceId: workspaceIdSchema.optional(), +}) satisfies z.ZodType>> + +/** workspace.insertBefore response value: the complete durable display order. */ +export const workspaceInsertBeforeValueSchema = z.object({ + workspaceIds: z.array(workspaceIdSchema), +}) satisfies z.ZodType>> + /** workspace.insertSessionBefore request payload (anchor omitted = append to end). */ export const workspaceInsertSessionBeforeRequestSchema = z.object({ workspaceId: workspaceIdSchema, diff --git a/packages/host/apiproxy/src/api/workspace.ts b/packages/host/apiproxy/src/api/workspace.ts index 64feb27f80..d36d0c406e 100644 --- a/packages/host/apiproxy/src/api/workspace.ts +++ b/packages/host/apiproxy/src/api/workspace.ts @@ -73,6 +73,15 @@ export interface WorkspaceApi { delete(request: RpcRequest<{ workspaceId: WorkspaceId }>): Promise> + /** + * Moves one Workspace within the registry display order, + * DOM-insertBefore-like. An omitted anchor appends to the end. + */ + insertBefore(request: RpcRequest<{ + workspaceId: WorkspaceId + beforeWorkspaceId?: WorkspaceId + }>): Promise> + /** * Moves an accounted session within its workspace's manual order, * DOM-insertBefore-like: with `beforeSessionId` the session is inserted diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index e4b6a2bed6..70e3ece58f 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -35,6 +35,7 @@ import { workspaceArchiveSessionValueSchema, workspaceCreateValueSchema, workspaceDeleteValueSchema, + workspaceInsertBeforeValueSchema, workspaceInsertSessionBeforeValueSchema, workspaceListValueSchema, workspaceRenameValueSchema, @@ -116,6 +117,7 @@ export interface IApiClient { create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise>> rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise>> delete(payload: RequestPayload<'workspace.delete'>, signal?: AbortSignal): Promise>> + insertBefore(payload: RequestPayload<'workspace.insertBefore'>, signal?: AbortSignal): Promise>> insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise>> archiveSession(payload: RequestPayload<'workspace.archiveSession'>, signal?: AbortSignal): Promise>> } @@ -193,6 +195,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('workspace.create', payload, signal), rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal), delete: (payload, signal) => this.callUnary('workspace.delete', payload, signal), + insertBefore: (payload, signal) => this.callUnary('workspace.insertBefore', payload, signal), insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal), archiveSession: (payload, signal) => this.callUnary('workspace.archiveSession', payload, signal), } diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 1361bb9b1f..4e8800348a 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -38,6 +38,7 @@ import { workspaceArchiveSessionRequestSchema, workspaceCreateRequestSchema, workspaceDeleteRequestSchema, + workspaceInsertBeforeRequestSchema, workspaceInsertSessionBeforeRequestSchema, workspaceListRequestSchema, workspaceRenameRequestSchema, @@ -112,6 +113,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) }, 'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) }, 'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) }, + 'workspace.insertBefore': { schema: workspaceInsertBeforeRequestSchema, invoke: (api, r) => api.workspace.insertBefore(r) }, 'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) }, 'workspace.archiveSession': { schema: workspaceArchiveSessionRequestSchema, invoke: (api, r) => api.workspace.archiveSession(r) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index 7c3c39e9e1..4c2506c395 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -319,6 +319,50 @@ describe('workspace.create', () => { }) }) +describe('workspace.insertBefore', () => { + it('commits the complete order, streams one order frame, and maps unknown ids', async () => { + const { api, ctx, root } = await harness() + const first = expectOk(await api.workspace.create(request({ path: stageDir(root, 'first') }))).workspace + const second = expectOk(await api.workspace.create(request({ path: stageDir(root, 'second') }))).workspace + const third = expectOk(await api.workspace.create(request({ path: stageDir(root, 'third') }))).workspace + + const abort = new AbortController() + const listWorkspaces = vi.spyOn(ctx.workspace, 'list') + const stream: AsyncIterator> = + api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() + expect(listWorkspaces).toHaveBeenCalledTimes(1) + const changed = nextHostFrame(stream) + const reordered = expectOk(await api.workspace.insertBefore(request({ + workspaceId: first.workspaceId, + beforeWorkspaceId: second.workspaceId, + }))) + expect(reordered.workspaceIds).toEqual([third.workspaceId, first.workspaceId, second.workspaceId]) + expect(await changed).toMatchObject({ + payload: { + type: 'host/workspace-order-changed', + workspaceIds: [third.workspaceId, first.workspaceId, second.workspaceId], + }, + }) + expect(expectOk(await api.workspace.list(request({}))).items.map(item => item.workspaceId)) + .toEqual(reordered.workspaceIds) + + const missingSource = await api.workspace.insertBefore(request({ + workspaceId: 'missing' as WorkspaceId, + })) + expect(missingSource.result).toMatchObject({ + ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'missing' } }, + }) + const missingAnchor = await api.workspace.insertBefore(request({ + workspaceId: first.workspaceId, + beforeWorkspaceId: 'missing-anchor' as WorkspaceId, + })) + expect(missingAnchor.result).toMatchObject({ + ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'missing-anchor' } }, + }) + abort.abort() + }) +}) + describe('session creation and Workspace membership', () => { it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => { const { api, ctx, root } = await harness() diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 15888b3cfb..4130d8f210 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -85,6 +85,7 @@ function scriptedApi(overrides: { create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }), rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), delete: r => ok(r, { deleted: true as const }), + insertBefore: r => ok(r, { workspaceIds: [r.payload.workspaceId] }), insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), archiveSession: r => ok(r, { archivedSessionIds: [r.payload.sessionId] }), }, @@ -218,7 +219,7 @@ describe('unary round trip', () => { expect(response.result).toEqual({ ok: true, value: { sessionId: 's-child' } }) }) - it('routes workspace rename, delete, and insertSessionBefore through the wire', async () => { + it('routes workspace rename, delete, and ordering through the wire', async () => { const api = scriptedApi() const c = client(api) const renamed = await c.workspace.rename({ workspaceId: 'w1' as never, title: 'next' }) @@ -227,6 +228,11 @@ describe('unary round trip', () => { expect(blankTitle.result).toMatchObject({ ok: false, error: { code: 'bad-request' } }) const deleted = await c.workspace.delete({ workspaceId: 'w1' as never }) expect(deleted.result).toEqual({ ok: true, value: { deleted: true } }) + const workspaceOrder = await c.workspace.insertBefore({ + workspaceId: 'w1' as never, + beforeWorkspaceId: 'w2' as never, + }) + expect(workspaceOrder.result).toEqual({ ok: true, value: { workspaceIds: ['w1'] } }) const anchored = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1'), beforeSessionId: sid('s2') }) expect(anchored.result.ok).toBe(true) const appended = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1') }) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 4ce3d084e0..2000f708ba 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -179,6 +179,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async delete(request) { return { rpcId: request.rpcId, result: { ok: true, value: { deleted: true as const } } } }, + async insertBefore(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { workspaceIds: [request.payload.workspaceId] } } } + }, async insertSessionBefore(request) { return { rpcId: request.rpcId, diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index c8e81964b9..b78a03de07 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -23,6 +23,7 @@ import { workspaceArchiveSessionRequestSchema, workspaceArchiveSessionValueSchema, workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, workspaceDeleteRequestSchema, workspaceDeleteValueSchema, + workspaceInsertBeforeRequestSchema, workspaceInsertBeforeValueSchema, workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema, workspaceListRequestSchema, workspaceListValueSchema, workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema, @@ -394,6 +395,17 @@ describe('workspace domain schemas', () => { expect(workspaceDeleteValueSchema.parse({ deleted: true })).toEqual({ deleted: true }) expect(() => workspaceDeleteValueSchema.parse({ deleted: false })).toThrow() }) + + it('insertBefore accepts an anchored or anchorless Workspace move and returns the complete order', () => { + expect(workspaceInsertBeforeRequestSchema.parse({ + workspaceId: 'w1', beforeWorkspaceId: 'w2', + }).beforeWorkspaceId).toBe('w2') + expect(workspaceInsertBeforeRequestSchema.parse({ workspaceId: 'w1' }).beforeWorkspaceId) + .toBeUndefined() + expect(() => workspaceInsertBeforeRequestSchema.parse({ beforeWorkspaceId: 'w2' })).toThrow() + expect(workspaceInsertBeforeValueSchema.parse({ workspaceIds: ['w2', 'w1'] }).workspaceIds) + .toEqual(['w2', 'w1']) + }) }) describe('skills domain schemas', () => { diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index 559144f6df..d63dd3fe2a 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/README.i18n.yaml @@ -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/preset/agent-presets/README.md -README.md: 28b9a31ed41e5fc41e38d6b0349c5bd9cbaeed9d -README.zh.md: 1d8d1481c20005b9e7fed5341aa26dca63dcd815 +README.md: 63bed95d192e6aeff6f484b63bdde711df0f1967 +README.zh.md: 505cb017a3a2439a11e0f3ed1c7a950893f5e7de diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index 28b9a31ed4..63bed95d19 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -18,7 +18,8 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal - `ctx.agentPresets.composedPreset(agentCtx): string | undefined` The preset one LIVE agent runs on, read from its scope chain rather than from its session — the only answer available for an agent whose durable header is still being built. - `ctx.agentPresets.recompose(agentCtx, id): Promise` Re-link one agent to a different preset's standing composition. Valid only while the agent has produced nothing — **the caller owns that check**; the new mount is ensured before the link moves, so a failure leaves the agent as it was. Refuses a broken preset like `mount()`. - `ctx.agentPresets.standingKeyFor(id?): Promise` The standing scope key a host reader with no agent (a cold transcript read) resolves preset registrations in; ensures the mount without starting an agent, session, or turn. Refuses a broken preset like `mount()`. -- `ctx.agentPresets.authorable: boolean` Whether any configured root has `user` trust, and therefore whether a preset can be created at all. +- `ctx.agentPresets.roots: readonly PresetRoot[]` The roots this roster scans — every configured root in order, then the derived harness-home root. Not `config.roots`: read this to answer whether a roster is composed at all, so one derivation decides it. +- `ctx.agentPresets.authorable: boolean` Whether any of those roots has `user` trust, and therefore whether a preset can be created at all. - `ctx.agentPresets.read(id): Promise` One preset's composition text, exactly as stored. - `ctx.agentPresets.copy(from, id, name?): Promise` Create a locally authored preset by copying an existing one's whole directory — the only authoring write. No composition text crosses this seam, so a copy is exactly as loadable as its source; the copied metadata keeps the source's description but never its name or roster order, and `name` (or the id fallback) is what distinguishes the rows. - `ctx.agentPresets.remove(id): Promise` Delete a locally authored preset; joined sessions keep their standing mount. Clears the user default when it named the preset just deleted: storing a default that does not exist yet is deliberate, but one this call removed will never be supplied again and would fail every session created without an explicit pick. @@ -86,9 +87,20 @@ Every read failure degrades to no metadata — absent, malformed, wrongly typed, |---|---|---| | `default` | required | Preset id mounted when a caller names none | | `roots` | `[]` | Scanned directories in precedence order; each supplies `path` (a leading `~` expands) and `trust` (defaults to `user`) | +| `includeUserRoot` | `true` | Append `/.agent-presets` as a `user` root, after every configured root | An absent root supplies no presets rather than failing: the user root does not exist until the first locally authored preset, and naming a default no root supplies already fails loud at resolution. +### The writable root is this package's, the shipped root is the app's + +`/.agent-presets` is where a person's own presets live, the way `/skills` is where their own skills live ([`dsh-skill-local`](../../skill/skill-local/README.md)), so the roster derives it rather than waiting for a deployment to remember it — a launcher that configures nothing still finds and authors presets. It is appended AFTER every configured root, which keeps an earlier root winning a duplicate id: a shipped `standard` still shadows a home directory that claimed the name, and `copy()` refuses that id rather than landing a preset nothing would resolve. + +The roots are resolved once, when the service is constructed. A root set that changed between a `list()` and the `copy()` acting on its answer would author into a directory the caller never saw. + +`includeUserRoot: false` mounts a roster over `roots` alone. A deployment that confines presets to its own directories needs it, and so does any test pinning an exact roster — otherwise the machine's real `` decides what the roster contains. + +The SHIPPED root stays an assembly fact: it sits beside the installed app's own config, a path only that app can resolve. + ### The default preset is a user setting When a settings provider is composed, this plugin registers the `agent-presets` namespace with `config.default` as its composition base, so the user document layers over the deployment's engineering default: @@ -132,6 +144,7 @@ Prefix-stable for the life of an agent: a composition is installed once, before ## Known Limitations and Deferred Work +- **A preset outside the writable root is discoverable but not deletable** — `remove()` refuses anything that does not live under the FIRST `user` root, so a deployment that configures its own writable root while leaving `includeUserRoot` on lists the harness-home presets, mounts them, and then answers "it does not live under the writable preset root" for every delete. The roster carries one writable root by design; a deployment that wants only its own sets `includeUserRoot: false`. - **A preset cannot be changed once a session has produced anything** — `recompose` re-links a BLANK session's parent scope to another standing mount, and only a blank one: switching a composition that already ran would strand tools the model has called. Changing the default affects only sessions created afterwards. - **A generation is keyed on the composition file alone** — the stamp check notices `agent.cordis.yml` changing, not an edit to a skill file or asset beside it; those reach new sessions only once the composition file itself moves or the process restarts. - **A superseded generation is never reclaimed** — sessions already joined keep the generation they run on, and the roster holds no join count that could tell when the last one left, so the whole subtree stays mounted until the process ends. The cost is per generation rather than per session, but it is not free: `dsh-skill-local` watches its roots by default, so each edit-then-create cycle adds a live watcher set. Bounded by how often compositions are edited — which the settings-page authoring flow makes a per-save event rather than a per-deploy one. Reclaiming one needs a joined-agent count on the standing mount; see the `TODO` at `ensureStanding`. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index 1d8d1481c2..505cb017a3 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -18,7 +18,8 @@ - `ctx.agentPresets.composedPreset(agentCtx): string | undefined` 某个**活着的** agent 正在运行的 preset,从其 scope 链读取而不是从其会话读取——对于持久化 header 尚在构建中的 agent,这是唯一能拿到的答案。 - `ctx.agentPresets.recompose(agentCtx, id): Promise` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。与 `mount()` 一样拒绝损坏的 preset。 - `ctx.agentPresets.standingKeyFor(id?): Promise` 没有 agent 的宿主读取方(冷读记录)解析 preset 注册所用的常驻 scope key;确保挂载而不启动任何 agent、会话或轮次。与 `mount()` 一样拒绝损坏的 preset。 -- `ctx.agentPresets.authorable: boolean` 是否有任一配置根目录具备 `user` 信任级别,因而 preset 是否可创建。 +- `ctx.agentPresets.roots: readonly PresetRoot[]` 本 roster 实际扫描的根目录——全部已配置根目录按序在前,随后是推导出的 harness home 根目录。它不是 `config.roots`:判断「是否已组装 roster」应读它,从而由同一处推导决定。 +- `ctx.agentPresets.authorable: boolean` 上述根目录中是否有任一具备 `user` 信任级别,因而 preset 是否可创建。 - `ctx.agentPresets.read(id): Promise` 某个 preset 的组装文本,与存储内容逐字一致。 - `ctx.agentPresets.copy(from, id, name?): Promise` 通过整目录复制一个既有 preset 来创建本地创作的 preset——唯一的创作写入。组装文本不经过这道接缝,因此副本与其来源同等可加载;复制出的元数据保留来源的描述、但绝不保留其名称与 roster 排序,`name`(或回退到 id)才是区分两行的依据。 - `ctx.agentPresets.remove(id): Promise` 删除一个本地创作的 preset;已加入的会话保留其常驻挂载。若用户默认值恰好指向刚删除的 preset 则一并清除:存一个尚不存在的默认值是刻意的,但本次删除的这个再也不会有人提供,留着会让所有未显式指定的新会话无法启动。 @@ -86,9 +87,20 @@ description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agen |---|---|---| | `default` | 必填 | 调用方未指定时挂载的 preset id | | `roots` | `[]` | 按优先级排列的扫描目录;每项提供 `path`(开头的 `~` 会展开)与 `trust`(默认为 `user`) | +| `includeUserRoot` | `true` | 在全部已配置根目录之后,追加 `/.agent-presets` 作为 `user` 根目录 | 根目录不存在时视为不提供任何 preset,而非失败:用户根目录在写出第一个本地 preset 之前并不存在,而指定了没有任何根目录提供的默认值,在解析时本就会明确报错。 +### 可写根目录属于本包,随附根目录属于 app + +`/.agent-presets` 是个人自有 preset 的所在,正如 `/skills` 是其自有 skill 的所在([`dsh-skill-local`](../../skill/skill-local/README.md)),因此 roster 自行推导它,而不等某个部署记得配置——一个什么都没配的启动器同样能发现并创作 preset。它追加在全部已配置根目录**之后**,从而保持靠前的根目录赢得重复 id:随附的 `standard` 仍然遮蔽一个占用该名字的家目录目录,而 `copy()` 会拒绝该 id,不会落下一个无人解析得到的 preset。 + +根目录在服务构造时解析一次。若根目录集合在一次 `list()` 与依据其答案执行的 `copy()` 之间发生变化,写入的将是调用方从未见过的目录。 + +`includeUserRoot: false` 使 roster 只覆盖 `roots`。把 preset 限制在自有目录内的部署需要它,任何钉住确切 roster 的测试同样需要——否则将由这台机器真实的 `` 决定 roster 的内容。 + +随附根目录仍然是装配事实:它位于已安装 app 自身配置的旁边,那个路径只有该 app 能解析。 + ### 默认 preset 是一项用户设置 当组装中存在 settings 提供方时,本插件会注册 `agent-presets` 命名空间,并以 `config.default` 作为其组装 base,因此用户文档会层叠覆盖部署方的工程默认值: @@ -132,6 +144,7 @@ Indirectly, through the plugins a standing composition registers, which own ever ## Known Limitations and Deferred Work +- **位于可写根目录之外的 preset 可被发现却无法删除** —— `remove()` 拒绝任何不在**第一个** `user` 根目录下的 preset,因此一个既配置了自有可写根、又保留 `includeUserRoot` 的部署,会列出并挂载 harness home 下的 preset,却对每次删除回答「它不在可写 preset 根目录之下」。roster 按设计只有一个可写根;只想要自有根的部署应设置 `includeUserRoot: false`。 - **会话一旦产出内容便无法更换 preset** —— `recompose` 把**空白**会话的父作用域重链到另一个常驻挂载,且仅限空白会话:切换已运行过的组装会抽走模型已调用的工具。更改默认值只影响此后创建的会话。 - **代际只以组装文件为键** —— stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。 - **被替代的代际永不回收** —— 已加入的会话保持其运行所在的代际,而名单没有加入计数可以判断最后一个何时离开,因此整棵子树一直挂到进程结束。代价按代际计而非按会话计,但并非为零:`dsh-skill-local` 默认监听自己的根目录,因此每一轮「编辑后建会话」都会新增一套活的 watcher。上限取决于组装被编辑的频率——而设置页的编写流程把这件事从「每次部署」变成了「每次保存」。要回收就需要给常驻挂载加上已加入 agent 的计数;见 `ensureStanding` 处的 `TODO`。 diff --git a/packages/preset/agent-presets/src/discovery.ts b/packages/preset/agent-presets/src/discovery.ts index 4ab3f67e50..51e1f30c95 100644 --- a/packages/preset/agent-presets/src/discovery.ts +++ b/packages/preset/agent-presets/src/discovery.ts @@ -25,6 +25,21 @@ import { PRESET_ID, type AgentPreset, type PresetRoot } from './preset.ts' /** The composition file that makes a directory a preset. */ export const COMPOSITION_FILE = 'agent.cordis.yml' +/** + * Harness-home directory holding locally authored presets. + * + * This package owns the writable root the way `dsh-skill-local` owns + * `/skills`. An app must assemble the SHIPPED root, whose path only + * the installed app can resolve; where a person's own presets go is the same + * place in every deployment that does not say otherwise, so a launcher that + * forgets to configure one still finds them. + * + * Package-internal on purpose: no consumer outside this package addresses the + * directory by name, and a test that imported it could not catch this value + * being wrong — the expected segment is spelled out where it is asserted. + */ +export const USER_PRESET_DIR = '.agent-presets' + /** * Why `rows` cannot be an entry list, or undefined when it can. * diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 3da5e3b5c9..a98eb92dd4 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -28,11 +28,12 @@ import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type // Type-only: resolves the `agent/created` lifecycle event this service watches. import type {} from '@deepseek-ai/dsh-agent' import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings' -import { discoverPresets } from './discovery.ts' +import { dshHomePath } from '@deepseek-ai/dsh-paths' +import { discoverPresets, USER_PRESET_DIR } from './discovery.ts' import { copyComposition, deleteComposition, readComposition } from './authoring.ts' import { mountPreset, serviceForAgent, standingMountFor } from './mount.ts' import { PresetExistsError } from './authoring.ts' -import { PresetMountError, UnknownPresetError, type AgentPreset, type Config } from './preset.ts' +import { PresetMountError, UnknownPresetError, type AgentPreset, type Config, type PresetRoot } from './preset.ts' import type {} from './types.ts' /** Settings namespace carrying the user's chosen default preset. */ @@ -88,8 +89,21 @@ export class AgentPresets extends Service { path: z.string().required(), trust: z.union(['system', 'user'] as const).default('user'), })).default([]), + includeUserRoot: z.boolean().default(true), }) as z + /** + * The roots discovery and authoring actually scan: every configured root in + * order, then the harness-home user root unless `includeUserRoot` is false. + * + * Derived once, because a root set that changed between `list()` and the + * `copy()` acting on its answer would author into a directory the caller + * never saw. Appending rather than prepending keeps an earlier configured + * root winning a duplicate id, so a shipped preset still shadows a + * locally authored directory that claimed its name. + */ + private readonly resolvedRoots: readonly PresetRoot[] + /** * The user layer over `config.default`, present only while a settings * provider is composed. Held rather than snapshotted so a hot-reloaded @@ -116,6 +130,9 @@ export class AgentPresets extends Service { constructor(ctx: Context, public config: Config) { super(ctx, 'agentPresets') this.selfCtx = ctx + this.resolvedRoots = config.includeUserRoot + ? [...config.roots, { path: dshHomePath(USER_PRESET_DIR), trust: 'user' }] + : [...config.roots] // Deliberately not `installSettingsSection`: that helper exists to re-judge // what a consumer DERIVED from the source — memoized resolutions, // registration-level facts — across attach, detach, and change. Nothing @@ -147,7 +164,7 @@ export class AgentPresets extends Service { // does that today — the Web surface mounts in `setup` and children join // through `composeFrom` before publication. ctx.on('agent/created', ({ agent }) => { - if (this.config.roots.length === 0) return + if (this.resolvedRoots.length === 0) return if (this.composedPreset(agent.ctx) !== undefined) return ctx.logger.warn( `agent "${agent.id}" was published without joining an agent preset; ` @@ -180,7 +197,7 @@ export class AgentPresets extends Service { * @returns the presets, first-root-wins per id. */ async list(): Promise { - return await discoverPresets(this.config.roots) + return await discoverPresets(this.resolvedRoots) } /** @@ -320,9 +337,19 @@ export class AgentPresets extends Service { return standingMountFor(agentCtx)?.presetId } - /** Whether this deployment configures a root locally authored presets go to. */ + /** + * The roots this roster scans, which is not `config.roots`: it is every + * configured root in order, then the harness-home user root unless + * `includeUserRoot` is false. Read this — not the config field — to answer + * whether a roster is composed at all, so one derivation decides it. + */ + get roots(): readonly PresetRoot[] { + return this.resolvedRoots + } + + /** Whether this deployment has a root locally authored presets go to. */ get authorable(): boolean { - return this.config.roots.some(root => root.trust === 'user') + return this.resolvedRoots.some(root => root.trust === 'user') } /** @@ -358,7 +385,7 @@ export class AgentPresets extends Service { if ((await this.list()).some(preset => preset.id === id)) { throw new PresetExistsError(id) } - await copyComposition(this.config.roots, source, id, name) + await copyComposition(this.resolvedRoots, source, id, name) // A settled mount under this id can only be stale (its preset was deleted // from disk outside `remove`); the new preset must not inherit it. Every // session already joined keeps the generation it runs on regardless. @@ -371,7 +398,7 @@ export class AgentPresets extends Service { * @throws when the preset is unknown or ships with the deployment. */ async remove(id: string): Promise { - await deleteComposition(this.config.roots, await this.resolve(id)) + await deleteComposition(this.resolvedRoots, await this.resolve(id)) // Sessions on the deleted preset keep their standing mount; only new // sessions see the roster without it. this.standing.delete(id) diff --git a/packages/preset/agent-presets/src/invariant.ts b/packages/preset/agent-presets/src/invariant.ts index e9240a0b2d..81cfd834ab 100644 --- a/packages/preset/agent-presets/src/invariant.ts +++ b/packages/preset/agent-presets/src/invariant.ts @@ -60,7 +60,7 @@ const install: InvariantInstaller = (ctx, fail) => { ctx.on('system-prompt/assemble', (_assembly, context, next) => { const presets = ctx.get('agentPresets') const agent = context.agent - if (presets !== undefined && presets.config.roots.length > 0 + if (presets !== undefined && presets.roots.length > 0 && agent !== undefined && presets.composedPreset(agent.ctx) === undefined) { fail( `agent "${agent.id}" addressed a model without joining any agent preset while a roster is ` diff --git a/packages/preset/agent-presets/src/preset.ts b/packages/preset/agent-presets/src/preset.ts index b2b48ea6ea..554348cdd6 100644 --- a/packages/preset/agent-presets/src/preset.ts +++ b/packages/preset/agent-presets/src/preset.ts @@ -54,6 +54,11 @@ export interface Config { default: string /** Scanned roots in precedence order; an earlier root wins a duplicate id. */ roots: PresetRoot[] + /** + * Append the harness home's `USER_PRESET_DIR` as a `user` root, after every + * configured root. False mounts a roster over `roots` alone. + */ + includeUserRoot: boolean } /** diff --git a/packages/preset/agent-presets/tests/authoring.spec.ts b/packages/preset/agent-presets/tests/authoring.spec.ts index df69a792d5..8086996111 100644 --- a/packages/preset/agent-presets/tests/authoring.spec.ts +++ b/packages/preset/agent-presets/tests/authoring.spec.ts @@ -52,6 +52,10 @@ beforeEach(async () => { { path: join(FIXTURES, 'system'), trust: 'system' as const }, { path: userRoot, trust: 'user' as const }, ], + // Every roster in this file pins its own roots: the derived harness-home + // root would add the developer's real presets to what these assertions + // count, and `copy` would write into it. + includeUserRoot: false, }) }) @@ -199,6 +203,7 @@ describe('a deployment with more than one user root', () => { { path: userRoot, trust: 'user' as const }, { path: second, trust: 'user' as const }, ], + includeUserRoot: false, }) // Writes go to the first user root, so a preset discovered from a later @@ -219,6 +224,7 @@ describe('a deployment with no writable root', () => { await readOnly.plugin(AgentPresets, { default: 'standard', roots: [{ path: join(FIXTURES, 'system'), trust: 'system' as const }], + includeUserRoot: false, }) expect(readOnly.agentPresets.authorable).toBe(false) @@ -240,6 +246,7 @@ describe('a user root that does not exist yet', () => { { path: join(FIXTURES, 'system'), trust: 'system' as const }, { path: absent, trust: 'user' as const }, ], + includeUserRoot: false, }) await fresh.agentPresets.copy('standard', 'mine') diff --git a/packages/preset/agent-presets/tests/invariant.spec.ts b/packages/preset/agent-presets/tests/invariant.spec.ts index 709ee5ba00..f73f77a53b 100644 --- a/packages/preset/agent-presets/tests/invariant.spec.ts +++ b/packages/preset/agent-presets/tests/invariant.spec.ts @@ -11,7 +11,7 @@ import AgentRegistry, { assembleContextFor } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import InvariantService from '@deepseek-ai/dsh-invariants' import { describe, expect, it } from 'vitest' -import AgentPresets, { livePresetMounts } from '@deepseek-ai/dsh-agent-presets' +import AgentPresets, { livePresetMounts, type Config } from '@deepseek-ai/dsh-agent-presets' import * as AgentPresetsInvariant from '@deepseek-ai/dsh-agent-presets/invariant' const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') @@ -20,7 +20,7 @@ const ROOTS = [ { path: join(FIXTURES, 'user'), trust: 'user' as const }, ] -async function harness(): Promise { +async function harness(roster: Partial = {}): Promise { const ctx = new Context() ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' await ctx.plugin(Loader) @@ -31,7 +31,7 @@ async function harness(): Promise { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS }) + await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS, includeUserRoot: false, ...roster }) await ctx.plugin(InvariantService) await ctx.plugin(AgentPresetsInvariant) return ctx @@ -97,6 +97,28 @@ describe('agent-presets invariants', () => { .rejects.toThrow(/without joining any agent preset/) }) + it('rejects one just the same when the derived home root is the whole roster', async () => { + // The shape this plugin defaults to: an app configures nothing and the + // roster is the harness home alone. A roster is a roster however its roots + // were resolved, so the fail-loud half must not go quiet here — it read + // `config.roots` once, which is empty in exactly this case. + const ctx = await harness({ roots: [], includeUserRoot: true }) + const handle = await ctx.agents.create({ sessionId: SessionId('inv-derived-only') }) + + await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent))) + .rejects.toThrow(/without joining any agent preset/) + }) + + it('stays silent for a composition that opted out of every root', async () => { + // `includeUserRoot: false` with no configured roots is a deployment that + // mounts the roster but keeps its agents on the host plane; there is no + // roster to join, so an unjoined agent is not a violation. + const ctx = await harness({ roots: [], includeUserRoot: false }) + const handle = await ctx.agents.create({ sessionId: SessionId('inv-no-roster') }) + + await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent))).resolves.toBeDefined() + }) + it('admits a joined agent, a scopeless read, and a standing-key read', async () => { const ctx = await harness() const handle = await ctx.agents.create({ diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 92a080a930..8901770434 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -38,7 +38,7 @@ const ROOTS = [ * @param roster - roster config, defaulting to the fixture roots. * @returns the booted context. */ -async function harness(roster: Config = { default: 'standard', roots: ROOTS }): Promise { +async function harness(roster: Config = { default: 'standard', roots: ROOTS, includeUserRoot: false }): Promise { const ctx = new Context() ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' await ctx.plugin(Loader) @@ -94,7 +94,7 @@ describe('composing an agent from a preset', () => { join(presetDir, COMPOSITION_FILE), `- id: only\n name: ${plugin}\n config:\n tool: absolute\n`, ) - const scoped = await harness({ default: 'absolute', roots: [{ path: root, trust: 'user' }] }) + const scoped = await harness({ default: 'absolute', roots: [{ path: root, trust: 'user' }], includeUserRoot: false }) const imported = vi.spyOn(scoped.loader.internal!, 'import') await agentOn(scoped, 'sess-absolute-plugin') @@ -347,7 +347,7 @@ describe('composing from a broken preset', () => { const root = await mkdtemp(join(tmpdir(), 'dsh-preset-broken-')) await mkdir(join(root, 'damaged')) await writeFile(join(root, 'damaged', COMPOSITION_FILE), composition) - return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }] }) + return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) } it('refuses the mount up front with the discovery-reported reason', async () => { @@ -380,7 +380,7 @@ describe('a roster with nothing in it', () => { it('says so instead of naming an empty list of candidates', async () => { const bare = new Context() await bare.plugin(Loader) - await bare.plugin(AgentPresets, { default: 'standard', roots: [] }) + await bare.plugin(AgentPresets, { default: 'standard', roots: [], includeUserRoot: false }) await expect(bare.agentPresets.resolve()) .rejects.toThrow(/preset "standard" not found \(available: none\)/) @@ -418,7 +418,7 @@ describe('the preset file is an input, never a persistence target', () => { await scoped.plugin(ToolRegistry) await scoped.plugin(AgentRegistry) await scoped.plugin(AgentLoop, { agents: [] }) - await scoped.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }] }) + await scoped.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) await scoped.agents.create({ sessionId: SessionId('sess-self-dispose'), @@ -528,11 +528,13 @@ describe('replacing a composition', () => { expect(warnings).toEqual([]) }) - it('says nothing when the deployment configures no roster at all', async () => { + it('says nothing when the composition opts out of every root', async () => { // Presets are optional: every surface except the Web bundle keeps its // model-facing rows in the host plane, so an agent with a chain of one is - // exactly right there and the diagnostic must stay silent. - const rosterless = await harness({ default: 'standard', roots: [] }) + // exactly right there and the diagnostic must stay silent. Opting out is + // what makes this rosterless — empty `roots` alone would still derive the + // harness-home root, which is a roster like any other. + const rosterless = await harness({ default: 'standard', roots: [], includeUserRoot: false }) const warnings: string[] = [] rosterless.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof rosterless.logger.warn @@ -581,7 +583,7 @@ describe('replacing a composition', () => { await scoped.plugin(ToolRegistry) await scoped.plugin(AgentRegistry) await scoped.plugin(AgentLoop, { agents: [] }) - await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }] }) + await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) const handle = await scoped.agents.create({ sessionId: SessionId('sess-restore-gone'), setup: async (agentCtx: Context) => void await scoped.agentPresets.mount(agentCtx, 'first'), @@ -621,7 +623,7 @@ describe('editing a composition file', () => { await mkdir(join(root, id)) const path = join(root, id, COMPOSITION_FILE) await writeFile(path, rowFor('before')) - const scoped = await harness({ default: id, roots: [{ path: root, trust: 'user' as const }] }) + const scoped = await harness({ default: id, roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) return { scoped, path } } diff --git a/packages/preset/agent-presets/tests/settings.spec.ts b/packages/preset/agent-presets/tests/settings.spec.ts index ef75eb8b78..49f1636a6c 100644 --- a/packages/preset/agent-presets/tests/settings.spec.ts +++ b/packages/preset/agent-presets/tests/settings.spec.ts @@ -49,7 +49,7 @@ async function harness( await ctx.plugin(AgentLoop, { agents: [] }) const settingsFiber = ctx.plugin(SettingsLocal, { path: settingsFile, watch: false }) await settingsFiber - await ctx.plugin(AgentPresets, { default: 'standard', roots: [...ROOTS, ...extraRoots] }) + await ctx.plugin(AgentPresets, { default: 'standard', roots: [...ROOTS, ...extraRoots], includeUserRoot: false }) return { ctx, settingsFile, settingsFiber } } diff --git a/packages/preset/agent-presets/tests/user-root.spec.ts b/packages/preset/agent-presets/tests/user-root.spec.ts new file mode 100644 index 0000000000..98c8123d1d --- /dev/null +++ b/packages/preset/agent-presets/tests/user-root.spec.ts @@ -0,0 +1,131 @@ +/** + * The writable root is this package's own, not an assembly fact each app must + * remember: a roster configured with only a `system` root still discovers and + * authors into `/.agent-presets`, the way `dsh-skill-local` owns + * `/skills`. `includeUserRoot: false` is how a deployment — or a test + * pinning an exact roster — opts out. + * + * `$DSH_HOME` is repointed per test because the derived root is resolved in the + * constructor: the plugin must be mounted while the environment names the + * temporary home, or it would reach the developer's real one. + */ + +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import AgentPresets, { COMPOSITION_FILE, type Config } from '@deepseek-ai/dsh-agent-presets' + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') +const SYSTEM_ROOT = join(FIXTURES, 'system') +/** Spelled out rather than imported: the convention is what these tests assert. */ +const USER_ROOT_SEGMENT = '.agent-presets' +const VALID = '- id: tool-alpha\n name: ../../plugins/contribute.js\n config:\n tool: alpha\n' + +let home: string +let previousHome: string | undefined + +beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'dsh-preset-home-')) + previousHome = process.env.DSH_HOME + process.env.DSH_HOME = home +}) + +afterEach(() => { + if (previousHome === undefined) delete process.env.DSH_HOME + else process.env.DSH_HOME = previousHome +}) + +/** Boot a roster over the fixture system root, with the derived root left to the plugin. */ +async function roster(config: Partial = {}): Promise { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.plugin(AgentPresets, { + default: 'standard', + roots: [{ path: SYSTEM_ROOT, trust: 'system' as const }], + includeUserRoot: true, + ...config, + }) + return ctx +} + +/** Hand-place a preset directory under the harness home's preset root. */ +async function seedHomePreset(id: string): Promise { + await mkdir(join(home, USER_ROOT_SEGMENT, id), { recursive: true }) + await writeFile(join(home, USER_ROOT_SEGMENT, id, COMPOSITION_FILE), VALID) +} + +describe('the harness-home preset root', () => { + it('is what a roster gets when config names no roots at all', () => { + // The schema default is the contract an app relies on by saying nothing; + // every other case here passes the field explicitly. The cast stands for + // the untyped document the Loader hands the schema, which is where a + // composition that omits the key actually comes from. + const parsed = AgentPresets.Config({ default: 'standard' } as unknown as Config) + + expect(parsed).toMatchObject({ includeUserRoot: true, roots: [] }) + }) + + it('is discovered without any app configuring it', async () => { + await seedHomePreset('mine') + const ctx = await roster() + + const listed = await ctx.agentPresets.list() + + expect(listed.find(preset => preset.id === 'mine')).toMatchObject({ trust: 'user' }) + expect((await ctx.agentPresets.resolve('mine')).path) + .toBe(join(home, USER_ROOT_SEGMENT, 'mine', COMPOSITION_FILE)) + }) + + it('makes a roster with only a system root authorable, and receives the copy', async () => { + const ctx = await roster() + + expect(ctx.agentPresets.authorable).toBe(true) + await ctx.agentPresets.copy('standard', 'copied') + + expect(existsSync(join(home, USER_ROOT_SEGMENT, 'copied', COMPOSITION_FILE))).toBe(true) + }) + + it('sorts after every configured root, so a shipped id still shadows a home directory', async () => { + // `standard` exists in the fixture system root; claiming the name at home + // must not take it over, because `copy` refuses an id any root supplies + // and a session resolving `standard` must reach the shipped composition. + await seedHomePreset('standard') + const ctx = await roster() + + expect((await ctx.agentPresets.resolve('standard')).trust).toBe('system') + await expect(ctx.agentPresets.copy('standard', 'standard')).rejects.toThrow(/already exists/) + }) + + it('is absent under includeUserRoot: false, which leaves the roster unauthorable', async () => { + await seedHomePreset('mine') + const ctx = await roster({ includeUserRoot: false }) + + expect((await ctx.agentPresets.list()).map(preset => preset.id)).not.toContain('mine') + expect(ctx.agentPresets.authorable).toBe(false) + await expect(ctx.agentPresets.copy('standard', 'mine')) + .rejects.toThrow(/no user-writable preset root/) + }) + + it('yields to a configured user root for authoring, which writableRoot takes first', async () => { + const explicit = await mkdtemp(join(tmpdir(), 'dsh-preset-explicit-')) + const ctx = await roster({ + roots: [ + { path: SYSTEM_ROOT, trust: 'system' as const }, + { path: explicit, trust: 'user' as const }, + ], + }) + + await ctx.agentPresets.copy('standard', 'copied') + + expect(existsSync(join(explicit, 'copied', COMPOSITION_FILE))).toBe(true) + expect(existsSync(join(home, USER_ROOT_SEGMENT, 'copied'))).toBe(false) + }) +}) diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index 1586df3947..389f23a011 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -32,6 +32,16 @@ async function setup(config: Config = {}, internals: LocalSandboxProvider['inter return { ctx, sandbox } } +/** + * A path inside a fresh temp dir where no file is written, pinning the + * built-entry `existsSync` check to false. Without it the resolution depends on + * whether the checkout has run `build:lib:host`, which emits + * `sandbox-windows-acl/lib/runner.js`. + */ +function absentRunnerEntry(): string { + return join(mkdtempSync(join(tmpdir(), 'dsh-absent-acl-entry-')), 'runner.js') +} + /** Write an executable fake `landlock-run` that answers `--probe` with `report`. */ function fakeLauncher(report = 'landlock: fully enforced'): string { const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-')) @@ -395,15 +405,31 @@ describe('the windows-acl probe (runner invocation contract)', () => { }) it('runs the REAL default probe against the resolved runner invocation when none is injected', async () => { - // The default probe spawns the exact runner argv confine would use — the - // runner source through tsx on a lib-less checkout. The windows-acl - // runner cannot init off win32, so the probe reads unusable and the walk - // falls through to the injected bwrap verdict on every host. + // No entry injected: this covers the production resolution through + // import.meta.resolve. Which arm of the existsSync check it takes depends + // on whether the checkout has run build:lib:host (which emits + // sandbox-windows-acl/lib/runner.js), so this asserts only what holds + // either way — the runner cannot init off win32, so the probe reads + // unusable and the walk falls through to the injected bwrap verdict. const { sandbox } = await setup({}, { chain: ['windows-acl', 'bwrap'], probeBwrap: () => true }) const confined = sandbox.confine(['true'], RO) expect(confined.argv[0]).toBe('bwrap') }, 30_000) + it('falls back to the runner source through tsx when the built entry is absent', async () => { + // The absent entry pins the source-through-tsx arm regardless of build + // state: on a checkout where build:lib:host has run, the real resolution + // above takes the built-entry arm instead and would leave this uncovered. + const { sandbox } = await setup({}, { + chain: ['windows-acl', 'bwrap'], + probeWindowsAcl: () => true, + windowsAclRunnerEntry: absentRunnerEntry(), + }) + const confined = sandbox.confine(['true'], RO) + expect(confined.argv.slice(0, 3)).toEqual([process.execPath, '--import', 'tsx/esm']) + expect(confined.argv[3]).toMatch(/runner\.ts$/) + }) + it('reads an empty runner invocation as unusable (the probe\'s empty-argv guard)', async () => { // windowsAclRunnerInvocation always yields [node, ...] in product; an // override returning [] exercises the default probe's empty-argv guard. diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 071eafbb0a..000a4c3db8 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -1384,6 +1384,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'delete(id: WorkspaceId): Promise', jsDoc: '/**\n * Delete one workspace registration while retaining its directory and every\n * session log. The durable order is updated before the table deletion; a\n * failed table write restores the prior order and keeps the entity\n * published. Unknown ids are an idempotent no-op for domain callers.\n * @param id - Workspace registration to remove.\n * @returns `true` when a record was deleted, `false` when it was unknown.\n */', }, + { + signature: 'insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise', + jsDoc: '/**\n * Move one workspace within the durable display order, DOM-insertBefore-like.\n * With an anchor it lands before that workspace; without one it appends.\n * @param id - Workspace to move.\n * @param beforeId - Workspace anchor; omitted appends.\n * @returns the complete committed workspace order.\n */', + }, { signature: 'archiveSession(sessionId: SessionId): Promise', jsDoc: '/**\n * Archive one session durably. The session must exist (live or in session\n * persistence); its workspace accounting — or lack of one — is irrelevant.\n * An already archived id resolves without writing.\n * @param sessionId - The session to archive.\n * @returns resolution after durability.\n */', diff --git a/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts index 28306f0dcc..b4c5d5736e 100644 --- a/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts @@ -40,7 +40,7 @@ async function setupPresetHost(): Promise<{ ctx: Context; adapter: MockAdapter; ctx.loader.builtins.include = Include await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS }) + await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS, includeUserRoot: false }) const adapter = new MockAdapter([textResponse('parent idle'), textResponse('child done')]) ctx.llm.registerAdapter(['mock'], adapter) const handle = await ctx.agents.create({ diff --git a/packages/workspace/workspace/README.i18n.yaml b/packages/workspace/workspace/README.i18n.yaml index 63d5ce0e8e..caeadcc3d8 100644 --- a/packages/workspace/workspace/README.i18n.yaml +++ b/packages/workspace/workspace/README.i18n.yaml @@ -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/workspace/workspace/README.md -README.md: 057765e38de9cc700210eb8edeb1ddc7ffc861ff -README.zh.md: 7416875dbf2ee1652f6e1fa1663144d7407a1ae7 +README.md: 4f7e2925ca7572dc3cc32c2a294bd1f40b243254 +README.zh.md: 2f4f38dea881b2c8a2bb135c8f7b1b3c88b9190a diff --git a/packages/workspace/workspace/README.md b/packages/workspace/workspace/README.md index 057765e38d..4f7e2925ca 100644 --- a/packages/workspace/workspace/README.md +++ b/packages/workspace/workspace/README.md @@ -10,9 +10,10 @@ The entity/storage rationale lives in the [domain Agent Note](../../../.agents/n - `ctx.workspace.create(path, title?)` — canonicalizes `path` via `fs.realpath`, rejects a nonexistent or non-directory path, creates at most one record per canonical path, and prepends a new record to durable workspace order. Repeated calls for that path return the existing workspace without changing its title; different paths may share a display title. - `ctx.workspace.get(id)` / `list()` / `resolveByPath(path)` — cache-served lookups. `list()` is synchronous and follows durable registry order; `resolveByPath` is async because it applies the same `realpath` canon and rejects a missing path rather than creating it. +- `ctx.workspace.insertBefore(id, before?)` — moves a registered Workspace within durable registry order, DOM-insertBefore-like: before the anchor, or appended when the anchor is omitted. A source or anchor absent from the registry rejects without writing; a self-anchor or move to the current position resolves without writing. The returned id list is the complete committed order. - `ctx.workspace.delete(id)` — removes only the Workspace registration, its durable order entry, and its session account. Unknown ids return `false`; a removed record returns `true`. The directory, user files, live Sessions, and persisted session logs are never touched, so those Sessions become Ungrouped. A table-write failure restores the prior order and published entity. - `Workspace.attachSession(id)` — validates a live or persisted session header cwd against the workspace path and prepends a new id. Unknown sessions, absent/unresolvable/non-directory cwd values, and mismatches reject without writing. `detachSession` removes only the candidate index entry. -- `Workspace.insertSessionBefore(id, before?)` — moves an accounted session within the manual order, DOM-insertBefore-like: before the anchor, or appended when the anchor is omitted. A session or anchor absent from the account rejects without writing; a move to the current position resolves without writing. Workspace order never changes. +- `Workspace.insertSessionBefore(id, before?)` — moves an accounted session within the manual order, DOM-insertBefore-like: before the anchor, or appended when the anchor is omitted. A session or anchor absent from the account rejects without writing; a move to the current position resolves without writing. Registry Workspace order never changes. - `ctx.workspace.archiveSession(id)` / `archivedSessionIds` — the registry-global archive set, layered over workspace accounting: an archived session disappears from grouping surfaces but keeps its session log and its `sessionIds` slot, so a future unarchive restores its position. Archiving accepts any live or persisted session (accounted or Ungrouped), resolves without writing for an already archived id, and rejects an unknown id. State written before the field existed parses with an empty set. - `Workspace.sessionIds` — synchronous id-plus-canonical-cwd membership projection in durable candidate order. Missing headers, invalid cwd values, and mismatches are filtered; the next workspace mutation prunes them. A medium indexing one session under two workspaces, claiming one path from two records, or diverging from durable workspace order rejects at startup. - `Workspace.status()` — uncached directory check, `'ok' | 'missing-dir'`; a missing directory never mutates the record. diff --git a/packages/workspace/workspace/README.zh.md b/packages/workspace/workspace/README.zh.md index 7416875dbf..2f4f38dea8 100644 --- a/packages/workspace/workspace/README.zh.md +++ b/packages/workspace/workspace/README.zh.md @@ -10,9 +10,10 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领 - `ctx.workspace.create(path, title?)`:规范化 `path` 时使用 `fs.realpath`,拒绝不存在或非目录的路径,每个规范路径最多创建一条记录,并将新记录前置到持久 workspace 顺序。对同一路径重复调用会返回现有 workspace,且不改变其标题;不同路径可以共用显示标题。 - `ctx.workspace.get(id)`/`list()`/`resolveByPath(path)`:由缓存提供的查找。`list()` 为同步操作,并遵循持久注册表顺序;`resolveByPath` 为异步操作,因为它采用相同的 `realpath` 规范化方式,并会拒绝缺失路径,而不是创建路径。 +- `ctx.workspace.insertBefore(id, before?)`:在持久注册表顺序内移动一个已注册 Workspace,语义类似 DOM 的 insertBefore:插到锚点之前,省略锚点则追加到末尾。来源或锚点不在注册表中时拒绝且不写入;以自身为锚点或移动到当前位置时直接完成且不写入。返回的 id 列表是完整的已提交顺序。 - `ctx.workspace.delete(id)`:只移除 Workspace 注册记录、对应的持久顺序条目及会话归属记录。未知 id 返回 `false`,成功移除记录则返回 `true`。目录、用户文件、活跃会话和持久化会话日志绝不受影响,因此相关会话会进入 Ungrouped。表写入失败时会恢复原顺序和此前发布的实体。 - `Workspace.attachSession(id)`:对照 workspace 路径验证实时或已持久化的会话头 cwd,并将新 id 前置。未知会话、缺失/无法解析/非目录的 cwd 值和不匹配情况都会在不写入的前提下被拒绝。`detachSession` 只移除候选索引条目。 -- `Workspace.insertSessionBefore(id, before?)`:在手动顺序内移动一个已记账的会话,语义类似 DOM 的 insertBefore:插到锚点之前,省略锚点则追加到末尾。会话或锚点不在记账中时拒绝且不写入;移动到当前位置时直接完成且不写入。Workspace 顺序绝不改变。 +- `Workspace.insertSessionBefore(id, before?)`:在手动顺序内移动一个已记账的会话,语义类似 DOM 的 insertBefore:插到锚点之前,省略锚点则追加到末尾。会话或锚点不在记账中时拒绝且不写入;移动到当前位置时直接完成且不写入。注册表中的 Workspace 顺序绝不改变。 - `ctx.workspace.archiveSession(id)`/`archivedSessionIds`:覆盖在 workspace 记账之上的注册表级全局归档集合:被归档的会话从各分组视图中消失,但其会话日志和 `sessionIds` 席位保持不变,未来取消归档时可恢复原位置。归档接受任何实时或已持久化的会话(无论已记账还是 Ungrouped),对已归档的 id 直接完成而不写入,并拒绝未知 id。在该字段出现之前写入的状态解析为一个空集合。 - `Workspace.sessionIds`:按持久候选顺序提供同步 id 加规范 cwd 成员投影。缺失头部、无效 cwd 值和不匹配情况都被过滤;下一次 workspace 变更会剪除它们。如果同一存储介质将一个会话索引到两个 workspace 下、用两条记录声明同一路径,或偏离持久 workspace 顺序,启动会被拒绝。 - `Workspace.status()`:未缓存的目录检查,返回 `'ok' | 'missing-dir'`;目录缺失绝不会改动记录。 diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index d972085939..5d1f3296d8 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -52,6 +52,17 @@ export class WorkspaceUnknownSessionError extends Error { } } +/** A workspace reorder named a source or anchor absent from the durable registry order. */ +export class WorkspaceOrderInvalidError extends Error { + /** + * @param workspaceId - Missing source or anchor id. + */ + constructor(readonly workspaceId: WorkspaceId) { + super(`cannot reorder unknown workspace '${workspaceId}'`) + this.name = 'WorkspaceOrderInvalidError' + } +} + declare module '@deepseek-ai/cordis' { interface Context { @@ -189,6 +200,30 @@ export class WorkspaceRegistry extends Service { return this.enqueueOperation(() => this.deleteKnown(id)) } + /** + * Move one workspace within the durable display order, DOM-insertBefore-like. + * With an anchor it lands before that workspace; without one it appends. + * @param id - Workspace to move. + * @param beforeId - Workspace anchor; omitted appends. + * @returns the complete committed workspace order. + */ + insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise { + return this.enqueueOperation(async () => { + const state = this.requireState() + if (!state.workspaceIds.includes(id)) throw new WorkspaceOrderInvalidError(id) + if (beforeId !== undefined && !state.workspaceIds.includes(beforeId)) { + throw new WorkspaceOrderInvalidError(beforeId) + } + if (beforeId === id) return state.workspaceIds + const without = state.workspaceIds.filter(workspaceId => workspaceId !== id) + const at = beforeId === undefined ? without.length : without.indexOf(beforeId) + const workspaceIds = [...without.slice(0, at), id, ...without.slice(at)] + if (sameIds(workspaceIds, state.workspaceIds)) return state.workspaceIds + await this.setState({ ...state, workspaceIds }) + return workspaceIds + }) + } + /** * The registry-global archive set: sessions hidden from every grouping * surface. Archiving never touches workspace accounting — an archived diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index 3c4b6185fb..c04980eecd 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -10,7 +10,11 @@ import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionHeader } from '@deepseek-ai/dsh-session' import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' -import WorkspaceRegistry, { WorkspaceId, WorkspaceMoveInvalidError } from '../src/index.ts' +import WorkspaceRegistry, { + WorkspaceId, + WorkspaceMoveInvalidError, + WorkspaceOrderInvalidError, +} from '../src/index.ts' import type { WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts' const DOMAIN_VERSION = 2 @@ -568,6 +572,49 @@ describe('WorkspaceRegistry create and lookup', () => { }) }) +describe('Workspace registry ordering', () => { + it('moves a workspace before an anchor or to the end and restores that order after restart', async () => { + const firstDir = await makeDir('order-first') + const secondDir = await makeDir('order-second') + const thirdDir = await makeDir('order-third') + const result = await harness() + const first = await result.registry.create(firstDir) + const second = await result.registry.create(secondDir) + const third = await result.registry.create(thirdDir) + expect(result.registry.list().map(item => item.id)).toEqual([third.id, second.id, first.id]) + + await expect(result.registry.insertBefore(first.id, second.id)) + .resolves.toEqual([third.id, first.id, second.id]) + await expect(result.registry.insertBefore(third.id)) + .resolves.toEqual([first.id, second.id, third.id]) + expect(storedState(result.pool).workspaceIds).toEqual([first.id, second.id, third.id]) + + const restarted = await harness({ pool: result.pool }) + expect(restarted.registry.list().map(item => item.id)).toEqual([first.id, second.id, third.id]) + }) + + it('keeps self-anchored and already-positioned moves write-free and rejects unknown ids', async () => { + const firstDir = await makeDir('order-noop-first') + const secondDir = await makeDir('order-noop-second') + const result = await harness() + const first = await result.registry.create(firstDir) + const second = await result.registry.create(secondDir) + const written = result.changes.length + + await result.registry.insertBefore(second.id, second.id) + await result.registry.insertBefore(second.id, first.id) + await result.registry.insertBefore(first.id) + expect(result.changes).toHaveLength(written) + expect(result.registry.list().map(item => item.id)).toEqual([second.id, first.id]) + + await expect(result.registry.insertBefore(WorkspaceId('missing'))) + .rejects.toBeInstanceOf(WorkspaceOrderInvalidError) + await expect(result.registry.insertBefore(second.id, WorkspaceId('missing-anchor'))) + .rejects.toMatchObject({ workspaceId: 'missing-anchor' }) + expect(result.changes).toHaveLength(written) + }) +}) + describe('Workspace session ordering', () => { it('prepends new attaches and keeps repeat attach idempotent', async () => { const dir = await makeDir('attach-order') diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index e13a2ccd31..d1a9bbc81b 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -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 python/sdk/README.md -README.md: 8cf366c27c8a604391ea85e298ba725e9987d428 -README.zh.md: a9258ce9aee9bce973107b49114d4ed6e81441e4 +README.md: 686cb46b6d3d12baaf2afdeba10def23d7a08edb +README.zh.md: 6414560deedbb76dd6f8571526251acd1c3f6a80 diff --git a/python/sdk/README.md b/python/sdk/README.md index 8cf366c27c..686cb46b6d 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -40,7 +40,7 @@ with DeepSeekHarness( `provider` selects a provider route registered by the chosen Cordis composition; `model` is the model id resolved by that adapter. `max_tokens` is an optional positive per-request output-token cap for the root agent and its in-process descendants; omission leaves the provider default in control. Compaction summaries keep the separate limit configured by their compaction plugin. The bundled default composition registers `deepseek-official`. A custom composition can mount `llm-pi-ai`, configure provider-specific credentials/endpoints there, and select any provider/model present in pi-ai's installed catalog. -The [Python SDK tutorial](../../docs/user/guide/python-sdk.md) uses a complete standalone Cordis file to demonstrate installation, direct SDK usage, and runs without the Web UI. +The [Python SDK tutorial](../../docs/user/guide/python-sdk.md) provides an ordered installation and first-run path without the Web UI. The [`jsonrpc-agent` example](../../examples/jsonrpc-agent/README.md) owns the complete standalone Cordis file used there. `Session.run()` owns an activity interval from its prompt's durable inbox receipt through the next whole-agent idle and returns `RunResult(session_id, final_response, finish_reason, events, notifications, session_root)`. `final_response` is the last committed root-session assistant text in the interval. `finish_reason` is the `kind` of the last root-session `turn/end` in the interval, such as `completed`, `max-tokens`, or `error`, and is `None` when no turn ended. A `turn/end` without a string `data.reason.kind` violates the runtime protocol and raises `SdkProtocolError`. Both result fields describe the owned interval rather than an output or ending causally assigned to the prompt. Steering, injected context, and other queued work may contribute before idle. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index a9258ce9ae..6414560dee 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -37,7 +37,7 @@ with DeepSeekHarness( `provider` 用于选择当前 Cordis 组合已注册的提供方路由;`model` 是该适配器解析的模型 ID。`max_tokens` 是可选的正整数,用于限制根 agent(智能体)及其进程内后代每次请求的输出 token;省略时由提供方默认值控制。压缩摘要继续使用压缩插件单独配置的上限。内置默认组合注册 `deepseek-official`。自定义组合可以挂载 `llm-pi-ai`,在其中配置各提供方的凭据与端点,再选择 pi-ai 已安装目录中的任意提供方/模型组合。 -[Python SDK 教程](../../docs/user/guide/python-sdk.md)使用完整的独立 Cordis 文件演示安装方式、直接调用 SDK,以及在不使用 Web UI 的情况下运行 agent。 +[Python SDK 教程](../../docs/user/guide/python-sdk.md)提供不使用 Web UI 的顺序安装与首次运行路径。[`jsonrpc-agent` 示例](../../examples/jsonrpc-agent/README.md)归属该教程使用的完整独立 Cordis 文件。 `Session.run()` 拥有一个从提示词进入持久 inbox 时开始、到整个 agent 下一次进入空闲状态为止的活动区间,并返回 `RunResult(session_id, final_response, finish_reason, events, notifications, session_root)`。`final_response` 是该区间内根会话最后提交的助手文本。`finish_reason` 是该区间内根会话最后一个 `turn/end` 的 `kind`,例如 `completed`、`max-tokens` 或 `error`;没有轮次结束时为 `None`。缺少字符串 `data.reason.kind` 的 `turn/end` 违反运行时协议,并会抛出 `SdkProtocolError`。两个结果字段描述的都是自有活动区间,而不是因果上归属于该提示词的输出或结束原因。steering(中途引导)、注入的上下文和其他排队工作都可能在进入空闲状态前参与其中。 diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 6d2784ea0d..a7c283d3ae 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,11 +8,11 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Run from source\n\nClone this repo, complete the [dependency and API-key setup](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key), then run:\n\n```sh\npnpm dsh web\n```\n\n## Use DeepSeek Harness\n\n### Web UI\n\nStart the recommended local interface from the repository root:\n\n```sh\npnpm dsh web\n```\n\nThe command builds the repository before starting the Web UI, which is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\nThe source CLI boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\npnpm dsh --profile web # the browser UI\npnpm dsh plugin --profile tui add # install a plugin into a custom profile\npnpm dsh --profile tui # boot it\n```\n\nThe [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n\n## Contributing\n\nRead [CONTRIBUTING.md](CONTRIBUTING.md) before contributing to this repository.\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Run\n\nInstall Node.js ^22.19 or >= 24 and pnpm 11, then run the published package:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\nThe command initializes the Web profile and prints the Web UI URL, which is `http://127.0.0.1:3080` by default. Open it, add a DeepSeek API key under **Settings → Models**, then start a session. The invoking directory is the default workspace; try `Summarize this repository and identify its main packages.`\n\nContinue with the [Web UI guide](docs/user/guide/).\n\n### Run from source\n\nTo run a repository checkout instead:\n\n```sh\ngit clone https://github.com/deepseek-harness/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm dsh web\n```\n\nThe last command builds the repository and opens the same Web UI path.\n\n## Profiles and plugins\n\nA profile is an ordered list of plugin bundles. The shipped `web` profile powers `dsh web`. Manage a profile with `dsh plugin --profile `, which forwards the remaining arguments to pnpm in that profile's directory:\n\n```sh\nnpx -p @deepseek-ai/dsh dsh plugin --profile web add \nnpx -p @deepseek-ai/dsh dsh plugin --profile web remove \n```\n\n`add`, `remove`, `update`, `why`, and other pnpm commands work unchanged. The command initializes a missing profile before changing its packages and updates its bundle list from installed packages that declare `dsh.bundle`. See the [CLI reference](apps/cli/reference/README.md#plugin-management) for the exact behavior.\n\nThe [CLI reference](apps/cli/README.md) covers headless execution and custom profiles. The [Python SDK](python/README.md) and [examples](examples/README.md) cover programmatic and custom compositions.\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n\n## Contributing\n\nRead [CONTRIBUTING.md](CONTRIBUTING.md) before contributing to this repository.\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 从源码运行\n\n克隆本仓库,完成[依赖安装和 API 密钥配置](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key),然后运行:\n\n```sh\npnpm dsh web\n```\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n请从仓库根目录启动推荐的本地界面:\n\n```sh\npnpm dsh web\n```\n\n该命令会先构建仓库,再启动 Web UI。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n源码 CLI(命令行界面)会启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\npnpm dsh --profile web # the browser UI\npnpm dsh plugin --profile tui add # install a plugin into a custom profile\npnpm dsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

    \n \"DeepSeek\n

    \n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n\n## 参与贡献\n\n向本仓库贡献前,请阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 运行\n\n安装 Node.js ^22.19 或 >= 24 和 pnpm 11,然后运行已发布的包:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\n该命令会初始化 Web profile 并打印 Web UI 地址,默认地址为 `http://127.0.0.1:3080`。打开该地址,在**设置 → 模型**中添加 DeepSeek API 密钥,然后启动一个会话。调用目录是默认工作区;你可以尝试输入 `Summarize this repository and identify its main packages.`。\n\n下一步请阅读 [Web UI 指南](docs/user/guide/)。\n\n### 从源码运行\n\n如需改为运行仓库 checkout:\n\n```sh\ngit clone https://github.com/deepseek-harness/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm dsh web\n```\n\n最后一条命令会构建仓库,并进入相同的 Web UI 路径。\n\n## Profile 与插件\n\nprofile 是按顺序排列的插件 bundle 列表。随附的 `web` profile 为 `dsh web` 提供功能。使用 `dsh plugin --profile ` 管理 profile;该命令会在对应 profile 目录中将剩余参数转发给 pnpm:\n\n```sh\nnpx -p @deepseek-ai/dsh dsh plugin --profile web add \nnpx -p @deepseek-ai/dsh dsh plugin --profile web remove \n```\n\n`add`、`remove`、`update`、`why` 等 pnpm 命令均可直接使用。该命令会先初始化不存在的 profile,再修改其中的包,并根据声明了 `dsh.bundle` 的已安装包更新 bundle 列表。准确行为见 [CLI 参考](apps/cli/reference/README.md#plugin-management)。\n\n[CLI(命令行界面)参考](apps/cli/README.md)介绍 headless 执行与自定义 profile。[Python SDK](python/README.md) 和[示例](examples/README.md)介绍程序化组合与自定义组合。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

    \n \"DeepSeek\n

    \n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n\n## 参与贡献\n\n向本仓库贡献前,请阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。\n" }, { "role": "user", diff --git a/tsconfig.client.json b/tsconfig.client.json index ce48a77ea9..c956867b7a 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -19,7 +19,10 @@ "packages/client/*/src/css-modules.d.ts", "packages/client/*/tests/**/*.ts", "packages/client/*/tests/**/*.tsx", - "packages/api/gateway/tests/client.spec.ts", + "packages/*/*/tests/**/*.client.spec.ts", + "packages/*/*/tests/**/*.client.spec.tsx", + "packages/*/*/tests/**/*.client.tsx", + "packages/*/*/tests/**/*.client.ts", "packages/client/tsdown.client.ts", "scripts/client-bundle-css.spec.ts", "scripts/client-bundle-purity.spec.ts" diff --git a/tsconfig.host.json b/tsconfig.host.json index d4e7f7ba3b..118078eb0d 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -29,6 +29,7 @@ "apps/web/tests/settings-chrome.e2e.ts", "apps/web/tests/models-settings.e2e.ts", "apps/web/tests/onboarding-deepseek-config.e2e.ts", + "apps/web/tests/onboarding-usable-provider.e2e.ts", "apps/web/tests/remote-welcome.e2e.ts", "apps/web/tests/workspace-management.e2e.ts", "apps/web/tests/replay-round-trip.e2e.ts", @@ -93,12 +94,11 @@ // and the package test glob above needs no per-file entry. "exclude": [ "packages/client/*/src/**", - "packages/client/*/tests/**/*.client.ts", - "packages/client/*/tests/**/*.client.tsx", - "packages/client/*/tests/**/*.client.spec.ts", - "packages/client/*/tests/**/*.client.spec.tsx", + "packages/*/*/tests/**/*.client.ts", + "packages/*/*/tests/**/*.client.tsx", + "packages/*/*/tests/**/*.client.spec.ts", + "packages/*/*/tests/**/*.client.spec.tsx", "packages/client/tsdown.client.ts", - "packages/api/gateway/tests/client.spec.ts", "scripts/client-bundle-css.spec.ts", "packages/typert/generator/tests/fixtures/**", "scripts/client-bundle-purity.spec.ts" diff --git a/website/docs.ts b/website/docs.ts index 2f25ae9d88..3cf1dab74c 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -115,44 +115,28 @@ const homeAndGuide = pairedPages([ }, { source: 'docs/user/guide/index.md', - route: 'guide/index.md', - label: { root: '介绍', en: 'Introduction' }, + route: 'guide/quickstart.md', + label: { root: '使用 Web UI', en: 'Use the Web UI' }, sidebar: { root: 'zh-guide', en: 'en-guide' }, section: { root: '入门', en: 'Guide' }, order: 1, sourceAliases: ['docs/user/guide'], }, - { - source: 'docs/user/guide/quickstart.md', - route: 'guide/quickstart.md', - label: { root: '快速开始', en: 'Quick start' }, - sidebar: { root: 'zh-guide', en: 'en-guide' }, - section: { root: '入门', en: 'Guide' }, - order: 2, - }, { source: 'docs/user/guide/providers.md', route: 'guide/providers.md', label: { root: '配置模型', en: 'Configure models' }, sidebar: { root: 'zh-guide', en: 'en-guide' }, section: { root: '入门', en: 'Guide' }, - order: 3, + order: 2, }, { source: 'docs/user/guide/python-sdk.md', route: 'guide/python-sdk.md', label: { root: 'Python SDK', en: 'Python SDK' }, sidebar: { root: 'zh-guide', en: 'en-guide' }, - section: { root: '入门', en: 'Guide' }, - order: 4, - }, - { - source: 'docs/user/guide/config.md', - route: 'guide/config.md', - label: { root: '配置文件', en: 'Configuration' }, - sidebar: { root: 'zh-guide', en: 'en-guide' }, - section: { root: '入门', en: 'Guide' }, - order: 5, + section: { root: '其他接口', en: 'Other interfaces' }, + order: 1, }, ])