Merge branch 'master' into fix/issue-4803-webkit-json
This commit is contained in:
commit
90cc7ce652
125 files changed
+3227
-328
No files matched your search
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-09-27-pwsh-prompt-tail-grace.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.md
|
||||
/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace:
|
||||
en: 5e8c5673a0669d9c
|
||||
zh: c8442a6186288a2a
|
||||
/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/problem:
|
||||
en: ac4fb08e3203cee9
|
||||
zh: 69fcfcb4f8fc0319
|
||||
/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/decision:
|
||||
en: 653c74442decb65a
|
||||
zh: 46f01c45d48b846b
|
||||
/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/alternatives-considered:
|
||||
en: 21a9a19b76f42758
|
||||
zh: 30a8f5425b59900d
|
||||
/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/consequences:
|
||||
en: b58904921286a1db
|
||||
zh: a696c2954d86c04f
|
||||
/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/testing:
|
||||
en: 78253191eea198fa
|
||||
zh: 32bdd6807ddc13a0
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: A seen prompt marker keeps its own tail grace
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-27-pwsh-prompt-tail-grace.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Windows sessions on the self-hosted lane settled one or two of seven sends on the controlled prompt and charged the silence tier for the rest (2026-09-25/26, issue #2487). The exact readiness evidence is the controlled prompt's printable tail after the OSC `133;D` marker, and the bound on waiting for it was `idleSilenceMs + handoffGraceMs`. A native Windows probe shows the render arrives as two pty chunks: the marker alone, then the five-byte `dsh> ` 2-28 ms later (56 renders), with nothing printable after any prompt and no terminal queries in the output. The marker is written by the shell's own prompt function and the tail by the same render, so a tail that arrives past the bound means the host stalled that delivery, not that the prompt is absent. The session settled `inferred_idle` anyway, and each such send cost the tool call roughly three seconds.
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-terminal-bash` gains a validated `promptTailGraceMs` configuration field, default `0`. While a send has seen the prompt marker and its printable tail has not arrived, the silence bound becomes `idleSilenceMs + handoffGraceMs + promptTailGraceMs`; every other bound, wait reason, and code path is unchanged. The [persistent PTY readiness design](../feature/2026-07-16-persistent-pty-sessions.md) owns the tier ladder this bound extends. Zero reproduces the previous bound exactly, so an upgrade changes no deployment's behaviour. The persistent pwsh Loader-composition case sets 5000 ms, because the hosts whose stalls the field is for are exactly the ones that run it, and its assertion (at least six `stdin_read` settlements, no `inferred_idle`) then measures the controlled-prompt path instead of the host's delivery timing. `validateConfig` accepts zero for this field — the one numeric bound whose zero is a documented value — and rejects negative or fractional values.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Raise the default instead of adding a field.** Rejected: it trades one fixed cost for another on every host. A marker whose tail never arrives (a stray marker from a child, a broken prompt function) would then fall back that much later everywhere, while the timing that matters differs per deployment.
|
||||
|
||||
**Make the observation sticky: once the exact tail was seen, let later output stand.** Rejected: the tail rule exists to prove the marker was followed by the prompt text rather than by a command's output, and a sticky flag would settle a send while that command is still printing.
|
||||
|
||||
**Wait for the tail up to the absolute `timeoutMs` once a marker was seen.** Rejected: a prompt that never completes its render would hang every send until the tool deadline instead of falling back after a bounded silence, and the send budget belongs to the caller.
|
||||
|
||||
**Detect the stall in the test and retry the send.** Rejected: retries hide which tier settled a send, and the durable fix belongs where the evidence is interpreted, not where it is measured.
|
||||
|
||||
## Consequences
|
||||
|
||||
A deployment whose console renderer stalls keeps those sends on the exact path by setting `promptTailGraceMs`; the default path is bit-for-bit the previous bound, so the negative control — the same delayed tail with the field at zero — still settles `inferred_idle`. The tolerance applies only after a marker was seen: a missing prompt keeps the plain silence bound, so the regression the Loader-composition case pins (a lost prompt must fail) is still caught, and a marker whose tail never arrives falls back one configured interval later.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/terminal/terminal-bash/tests/session.spec.ts` pins every state with fake timers: with `promptTailGraceMs` set, a marker whose tail arrives after the plain bound stays pending and then settles `stdin_read`; a marker whose tail never arrives falls back to `inferred_idle` at the extended bound; a tail that arrived and was invalidated by later output keeps the plain bound; and with the field at zero the same delayed delivery settles `inferred_idle`. `packages/terminal/terminal-bash/tests/config.spec.ts` accepts zero and rejects negative or fractional values, and rejects a nonzero tolerance shorter than one `pollIntervalMs`. `packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` carries the end-to-end evidence on native Windows: with every session's prompt tail withheld for four seconds the case passes with the configured tolerance — nine settles, eight `stdin_read` and the `exit` command's `session_exit`, in 33.4 s and 37.5 s — and fails with the tolerance patched to zero, where two sends settle `inferred_idle` with `promptSeen` true, `promptTextSeen` false, an empty tail and `idleFor` 3312-3316 ms.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: 已看到的提示符标记保留自己的尾部宽限
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-27-pwsh-prompt-tail-grace.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
自托管 lane 上的 Windows 会话在七次 send 中只有一到两次按受控提示符结算,其余都要支付静默层(2026-09-25/26,issue #2487)。精确的就绪证据是 OSC `133;D` 标记之后受控提示符的可打印尾部,而等待它的上界是 `idleSilenceMs + handoffGraceMs`。原生 Windows 探测表明该渲染以两个 pty 分块到达:先是标记本身,2–28 毫秒后是 5 字节的 `dsh> `(56 次渲染),任何提示符之后都没有可打印文本,输出中也没有终端查询。标记由 shell 自己的 prompt 函数写出、尾部由同一次渲染写出,因此尾部晚于上界到达意味着宿主机拖慢了这次投递,而不是提示符缺失。会话此前仍会按 `inferred_idle` 结算,每次这样的 send 让工具调用多付约三秒。
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-terminal-bash` 新增一个经过校验的 `promptTailGraceMs` 配置字段,默认 `0`。当某次 send 已看到提示符标记而其可打印尾部尚未到达时,静默上界变为 `idleSilenceMs + handoffGraceMs + promptTailGraceMs`;其他上界、等待原因与代码路径均不变。该上界所扩展的层级阶梯由[持久 PTY 就绪设计](../feature/2026-07-16-persistent-pty-sessions.zh.md)拥有。零精确复现此前的上界,因此升级不改变任何部署的行为。持久 pwsh 的 Loader 组合用例设置 5000 毫秒,因为需要该字段的宿主机正是运行该用例的那些,而其断言(至少六次 `stdin_read` 结算、不含 `inferred_idle`)由此度量受控提示符路径,而不是宿主机的投递时序。`validateConfig` 允许该字段取零——这是唯一一个零为文档化取值的数值上界——并拒绝负数与小数。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**不加字段,直接提高默认值。** 不采纳:那是把一种固定成本换成另一种,且对所有宿主机生效。尾部永不到达的标记(子进程打印的杂散标记、损坏的 prompt 函数)会在所有地方都多等那么久,而真正重要的时序因部署而异。
|
||||
|
||||
**让观测变为粘性:一旦看到精确尾部,后续输出不再使其失效。** 不采纳:尾部规则的存在就是为了证明标记之后跟着的是提示符文本而不是命令输出;粘性标志会在命令仍在打印时结算该 send。
|
||||
|
||||
**看到标记后一直等到绝对 `timeoutMs`。** 不采纳:渲染永不完成的提示符会让每次 send 都挂到工具截止时间,而不是在有界静默后回退,而 send 预算属于调用方。
|
||||
|
||||
**在测试里检测停摆并重试该 send。** 不采纳:重试会掩盖究竟是哪一层结算的,而持久的修复属于解释证据的地方,而不是测量证据的地方。
|
||||
|
||||
## Consequences
|
||||
|
||||
控制台渲染器会停摆的部署通过设置 `promptTailGraceMs` 让这些 send 留在精确路径上;默认路径与此前的上界逐位一致,因此负控——同样的尾部延迟、字段为零——仍然按 `inferred_idle` 结算。该宽限只在看到标记之后生效:提示符缺失时保持普通静默上界,所以 Loader 组合用例所固定的回归(提示符丢失必须失败)仍会被抓住,而尾部永不到达的标记只会晚一个配置区间回退。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/terminal/terminal-bash/tests/session.spec.ts` 用假定时器固定每一种状态:设置 `promptTailGraceMs` 时,尾部晚于普通上界到达的标记保持挂起,随后按 `stdin_read` 结算;尾部始终不到时在扩展上界处回落到 `inferred_idle`;尾部已到达但被后续输出失效时保持普通上界;字段为零时,同样的延迟投递按 `inferred_idle` 结算。`packages/terminal/terminal-bash/tests/config.spec.ts` 接受零、拒绝负数或小数,并拒绝非零但短于一个 `pollIntervalMs` 的宽限。`packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` 承载原生 Windows 上的端到端证据:把每个 session 的提示符尾部扣留四秒时,配置了该宽限后用例通过——九次结算、八次 `stdin_read` 加 `exit` 命令的 `session_exit`,耗时 33.4 秒与 37.5 秒;把该宽限改为零后同一停摆失败,其中两次 send 以 `inferred_idle` 结算,`promptSeen` 为 true、`promptTextSeen` 为 false、尾部为空且 `idleFor` 为 3312–3316 毫秒。
|
||||
@@ -24,8 +24,8 @@
|
||||
en: b92715f36550c728
|
||||
zh: e18d7e0e2363347a
|
||||
/agent-note-persistent-pty-sessions/decision/local-readiness-detection:
|
||||
en: cd8b4032a624c277
|
||||
zh: 47f0d8c15f1c9cad
|
||||
en: 8f36f06869bd5367
|
||||
zh: f3560477a8965cbe
|
||||
/agent-note-persistent-pty-sessions/decision/model-visible-output-and-durability:
|
||||
en: 1b504ab41b47216d
|
||||
zh: 8fb3d360b3a4e6a1
|
||||
|
||||
@@ -72,13 +72,13 @@ With `run_in_background: true`, `dsh-tool-terminal` registers the in-flight send
|
||||
|
||||
### Local readiness detection
|
||||
|
||||
The local backend first recognizes a private OSC prompt marker emitted by its controlled bash startup, then requires the printable tail after the latest marker to exactly equal the controlled `PS1` before declaring prompt readiness and runs three bounded fallback tiers. Carrying that tail across data callbacks covers delivery where the marker and prompt arrive separately; requiring the exact tail rejects a delayed earlier prompt once echoed input or output follows it, so it cannot settle the current send. The marker is removed before output reaches the model and avoids a fixed silence delay for ordinary shell commands on both platforms. Unpublished startup does not accept zero-output silence as readiness; timeout rejects the spawn. If caller cancellation wins during startup, the backend closes the private session and propagates the exact `AbortSignal.reason`; a foreground PGID that is not observable yet cannot replace cancellation with a lookup error. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, `handoffGraceMs`, and `timeoutMs`.
|
||||
The local backend first recognizes a private OSC prompt marker emitted by its controlled bash startup, then requires the printable tail after the latest marker to exactly equal the controlled `PS1` before declaring prompt readiness and runs three bounded fallback tiers. Carrying that tail across data callbacks covers delivery where the marker and prompt arrive separately; requiring the exact tail rejects a delayed earlier prompt once echoed input or output follows it, so it cannot settle the current send. The marker is removed before output reaches the model and avoids a fixed silence delay for ordinary shell commands on both platforms. Unpublished startup does not accept zero-output silence as readiness; timeout rejects the spawn. If caller cancellation wins during startup, the backend closes the private session and propagates the exact `AbortSignal.reason`; a foreground PGID that is not observable yet cannot replace cancellation with a lookup error. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, `handoffGraceMs`, `promptTailGraceMs`, and `timeoutMs`.
|
||||
|
||||
On Linux, the inspector reads the shell's terminal foreground PGID from `/proc/<shellPid>/stat`, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct `read(0)`, a permitted read of a `select`/`pselect6` or `poll`/`ppoll` argument containing fd 0, or an epoll interest list containing fd 0. The waiting thread's `/proc/<pid>/task/<tid>/fd/0` must identify the shell's controlling terminal device, so a thread-local fd table cannot substitute its leader's terminal descriptor and a pipeline reader blocked on its pipe remains a running command. Direct PTY descriptors use their device number; `/dev/tty` uses the owning process's `tty_nr` because `stat` reports the alias device rather than the selected PTY. A wait already present before terminal input is not post-write readiness: the same PGID must be observed outside that wait before re-entering it, while a changed foreground PGID is new evidence. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. A host policy that denies `/proc/<pid>/task/<tid>/syscall`, including hardened ptrace policy, likewise skips Tier 1 and preserves the bounded Tier 2 idle inference; process sleep state never substitutes for inaccessible syscall evidence. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; the inspector admits the runtime architecture, then checks every supported kernel ABI because `/proc` can report a different ABI under user-mode emulation. Unsupported runtime architectures skip Tier 1.
|
||||
|
||||
On macOS there is no exact syscall tier. Output silence returns `inferred_idle` for any foreground process group, including Python and `gdb`; `ps`-derived terminal PGID is used for signaling, not as proof that only the shell can be idle. Pure process-inspector logic is injectable and unit-tested on Linux, while a macOS CI job exercises the real PTY and process-table path.
|
||||
|
||||
Tier 2 returns `inferred_idle` after `idleSilenceMs` without output. A sleeping or network-blocked command can therefore look ready. When a prompt marker was already seen, Tier 2 waits a further `handoffGraceMs` so a bash foreground handoff that lands on the silence boundary still settles as the exact `stdin_read` attribution instead of the weaker inference; the grace is a deployment-owned config field validated to cover at least one `pollIntervalMs`, because a grace shorter than the poll period cannot contain a single readiness poll and so cannot change any outcome. It bounds only sends that saw a marker, so its cost is the interactive return latency of that one case rather than every send. Tier 3 returns `timeout` after `timeoutMs` so a foreground tool call cannot hold the agent indefinitely. The result preserves the distinction; callers may wait through `ctx.jobs`, signal the foreground group, or inspect from another session.
|
||||
Tier 2 returns `inferred_idle` after `idleSilenceMs` without output. A sleeping or network-blocked command can therefore look ready. When a prompt marker was already seen, Tier 2 waits a further `handoffGraceMs` so a bash foreground handoff that lands on the silence boundary still settles as the exact `stdin_read` attribution instead of the weaker inference; the grace is a deployment-owned config field validated to cover at least one `pollIntervalMs`, because a grace shorter than the poll period cannot contain a single readiness poll and so cannot change any outcome. A marker whose printable tail has not arrived yet adds `promptTailGraceMs` to that bound, because the marker and its tail are written by the same prompt render and a contended host delays only the tail; the [prompt tail grace decision](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.md) owns the field, and it applies only while the tail can still complete. It bounds only sends that saw a marker, so its cost is the interactive return latency of that one case rather than every send. Tier 3 returns `timeout` after `timeoutMs` so a foreground tool call cannot hold the agent indefinitely. The result preserves the distinction; callers may wait through `ctx.jobs`, signal the foreground group, or inspect from another session.
|
||||
|
||||
Once a send settles under any tier, `TerminalSendOperation.append` stops accepting output, so later child output no longer reaches that settled operation; it still reaches the scrollback, and any send that is active when it arrives. A test that waits for a marker on the operation it started must therefore set `idleSilenceMs` and `timeoutMs` above the child's own startup latency; interpreter startup on a loaded macOS runner otherwise ends the send before the marker is printed.
|
||||
|
||||
|
||||
@@ -72,13 +72,13 @@ UI 渲染约定精确且不携带位置信息。`terminal_send` 只为前台发
|
||||
|
||||
### 本地就绪检测
|
||||
|
||||
本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker,并且只有在最近一个 marker 后的可打印尾部与受控 `PS1` 完全相等时才声明 prompt 就绪;除此之外,它还运行 3 个有界 fallback 层级。在 data callback 之间保留该尾部,可以适配 marker 与 prompt 被分开交付的情况;如果回显的输入或输出跟在延迟到达的先前 prompt 之后,要求尾部完全相等会拒绝该 prompt,使其无法完成当前 send。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。尚未发布的 startup 不会把零输出静默视为就绪;timeout 会拒绝 spawn。若调用方取消在 startup 期间胜出,后端会关闭私有会话并原样抛出 `AbortSignal.reason`;尚不可观察的前台 PGID 不会再用查找错误覆盖取消原因。所有时间参数都是经校验的配置字段:`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs`、`handoffGraceMs` 和 `timeoutMs`。
|
||||
本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker,并且只有在最近一个 marker 后的可打印尾部与受控 `PS1` 完全相等时才声明 prompt 就绪;除此之外,它还运行 3 个有界 fallback 层级。在 data callback 之间保留该尾部,可以适配 marker 与 prompt 被分开交付的情况;如果回显的输入或输出跟在延迟到达的先前 prompt 之后,要求尾部完全相等会拒绝该 prompt,使其无法完成当前 send。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。尚未发布的 startup 不会把零输出静默视为就绪;timeout 会拒绝 spawn。若调用方取消在 startup 期间胜出,后端会关闭私有会话并原样抛出 `AbortSignal.reason`;尚不可观察的前台 PGID 不会再用查找错误覆盖取消原因。所有时间参数都是经校验的配置字段:`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs`、`handoffGraceMs`、`promptTailGraceMs` 和 `timeoutMs`。
|
||||
|
||||
在 Linux 上,检查器从 `/proc/<shellPid>/stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6` 或 `poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。等待线程的 `/proc/<pid>/task/<tid>/fd/0` 还必须标识 shell 的控制终端设备,因此线程本地 fd 表无法用 leader 的终端描述符冒充自身 fd,阻塞于管道的流水线读取端仍属于正在运行的命令。直接 PTY 描述符使用其设备号;`/dev/tty` 则使用所属进程的 `tty_nr`,因为 `stat` 报告的是别名设备而非选定的 PTY。终端输入前就已存在的等待并不代表写入后就绪:必须先观察到同一 PGID 脱离该等待,之后再次进入等待才能使该次 send 完成;前台 PGID 发生变化则构成新的证据。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。宿主策略(包括加固的 ptrace 策略)若拒绝读取 `/proc/<pid>/task/<tid>/syscall`,同样会跳过 Tier 1 并保留有界的 Tier 2 idle 推断;进程休眠状态绝不会代替不可访问的 syscall 证据。架构表只包含对应 Linux UAPI 定义的 syscall number;检查器先准入运行时架构,再检查每个受支持的内核 ABI,因为在用户态模拟下,`/proc` 可能报告不同的 ABI。不受支持的运行时架构会跳过 Tier 1。
|
||||
|
||||
macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 `inferred_idle`,包括 Python 和 `gdb`;从 `ps` 推导的终端 PGID 只用于发送信号,不作为「只有 shell 才能 idle」的证明。纯进程检查逻辑可注入,并在 Linux 上经过单元测试,同时由 macOS CI job 驱动真实 PTY 和进程表路径。
|
||||
|
||||
Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。如果此前已经见过 prompt marker,Tier 2 会再等待 `handoffGraceMs`,使恰好落在静默边界上的 bash 前台交接仍然以精确的 `stdin_read` 归因结束,而不是退到较弱的推断;该宽限是由部署方拥有的配置字段,并被校验为至少覆盖一个 `pollIntervalMs`——短于轮询周期的宽限装不下一次就绪轮询,因此不可能改变任何结果。它只约束见过 marker 的 send,代价是这一种情况的交互返回延迟,而不是每一次 send。Tier 3 在 `timeoutMs` 后返回 `timeout`,避免前台工具调用无限占住 agent。结果保留这些区别;调用方可以通过 `ctx.jobs` 等待、向前台组发信号,或从另一个会话排查。
|
||||
Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。如果此前已经见过 prompt marker,Tier 2 会再等待 `handoffGraceMs`,使恰好落在静默边界上的 bash 前台交接仍然以精确的 `stdin_read` 归因结束,而不是退到较弱的推断;该宽限是由部署方拥有的配置字段,并被校验为至少覆盖一个 `pollIntervalMs`——短于轮询周期的宽限装不下一次就绪轮询,因此不可能改变任何结果。若已见 marker 而其可打印尾部尚未到达,该上界还会加上 `promptTailGraceMs`:marker 与尾部由同一次提示符渲染写出,负载高的宿主机只会拖慢尾部;该字段由[提示符尾部宽限决策](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md)拥有,且只在尾部仍可能补齐时生效。它只约束见过 marker 的 send,代价是这一种情况的交互返回延迟,而不是每一次 send。Tier 3 在 `timeoutMs` 后返回 `timeout`,避免前台工具调用无限占住 agent。结果保留这些区别;调用方可以通过 `ctx.jobs` 等待、向前台组发信号,或从另一个会话排查。
|
||||
|
||||
一次 send 在任一层级 settle 之后,`TerminalSendOperation.append` 就不再接受输出,此后子进程的输出不会再进入那个已 settle 的 operation;它仍然会进入 scrollback,以及此时恰好处于活跃状态的任何 send。因此,等待自己所启动的 operation 上出现标记的测试,必须把 `idleSilenceMs` 与 `timeoutMs` 设得高于子进程自身的启动耗时;否则在负载较高的 macOS runner 上,解释器启动会在标记打印之前就结束这次 send。
|
||||
|
||||
|
||||
+4
-4
@@ -9,11 +9,11 @@
|
||||
en: 4fb1c27a631b32d3
|
||||
zh: 9dbe9a4b9aadfca9
|
||||
/agent-note-serial-cross-platform-ci-reference/decision:
|
||||
en: f547014d316c1385
|
||||
zh: f13b09a4a778adaa
|
||||
en: 7c58d88d4c002f19
|
||||
zh: a3b961be49f2a96b
|
||||
/agent-note-serial-cross-platform-ci-reference/alternatives-considered:
|
||||
en: bc72a1e194e3b0e2
|
||||
zh: 9a92cbac88adc7a7
|
||||
en: 3610152c5c4e93af
|
||||
zh: f4c09d327716f750
|
||||
/agent-note-serial-cross-platform-ci-reference/consequences:
|
||||
en: 7042125ce6a209d4
|
||||
zh: ef2be4fa8a4d6320
|
||||
@@ -24,7 +24,7 @@ Platform ownership remains explicit inside that complete aggregate. `terminal-ba
|
||||
|
||||
The macOS reference runs the ordinary Vitest project in forked processes. Node 24 on macOS arm64 has aborted in its CJS lexer from a worker thread; the process boundary contains that external runtime failure without removing any test from the aggregate, while Linux and Windows retain the lower-overhead thread pool. Repository-owned races are fixed at their observation boundaries: dev bundle polling stages each candidate table, graph, and watch-baseline map before publishing a rescan, and a missing bundle remains dirty until a successful content hash. PTY readiness retains a prompt candidate while polling checks foreground ownership; the ordinary silence bound covers inherited markers from interactive children. Real PTY fixtures assemble synchronization tokens at runtime so the interactive shell's input echo cannot satisfy a child-readiness wait. The live-link package-manager e2e preserves the workflow-prepared Corepack home and pnpm metadata/store caches while isolating the other managers' mutable caches, so it does not discard reusable package-manager state before the install.
|
||||
|
||||
The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow belongs to the reference side of the same split. Its bwrap, Landlock x64/arm64, and Seatbelt real-kernel matrix runs only after a push to `master`. Those four jobs are diagnostic: they are not branch-protection requirements and do not feed `all checks passed` across workflow files. Pull-request CI still checks sandbox source through its ordinary unit and coverage inventory; the host-kernel and packed-install proofs report after merge.
|
||||
The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow belongs to the reference side of the same split. Its bwrap, Landlock x64/arm64, and Seatbelt real-kernel matrix runs only after a push to `master`. Those four jobs are diagnostic: they are not branch-protection requirements and do not feed `all checks passed` across workflow files. A fifth job, `unit-darwin`, runs the complete unit suite on `macos-latest` under its own budget, so the darwin-only unit expectations keep a platform oracle without holding the Seatbelt leg to the unit suite's duration. Pull-request CI still checks sandbox source through its ordinary unit and coverage inventory; the host-kernel and packed-install proofs report after merge.
|
||||
|
||||
Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. The ci-master and Sandbox workflows keep their cross-platform references on master pushes. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value.
|
||||
|
||||
@@ -35,7 +35,7 @@ The active serial references run on the self-hosted `vm-backup` (`serial / linux
|
||||
- **Set each timeout equal to its latency target** - rejected because scheduling variance would cancel correct work and suppress the evidence needed to diagnose a regression.
|
||||
- **Trust only the concurrent primary inventory** - rejected because scheduling and validation share implementation assumptions; a serial aggregate is an independent completeness check.
|
||||
- **Run the serial references on every pull request** - rejected because they duplicate complete cross-platform aggregates and add macOS work to every change; the required jobs already execute the blocking Linux and native Windows checks, and the other native jobs supply the complete Windows result.
|
||||
- **Run the real-kernel Sandbox matrix on every pull request** - rejected because its four statuses do not participate in branch protection, while repeated installs, Landlock builds, and macOS unit parity consume runner capacity without changing the merge verdict. The master run retains the platform and installed-launcher signal.
|
||||
- **Run the real-kernel Sandbox matrix on every pull request** - rejected because its statuses do not participate in branch protection, while repeated installs, Landlock builds, and macOS unit parity consume runner capacity without changing the merge verdict. The master run retains the platform and installed-launcher signal.
|
||||
- **Use one operating-system matrix** - rejected because three named jobs make the reference surface visible without another selection mechanism.
|
||||
- **Run the serial reference on larger runners** - rejected because both required CI and its independent reference must remain runnable when organization-owned pools cannot allocate jobs.
|
||||
|
||||
|
||||
+2
-2
@@ -24,7 +24,7 @@ Status: implemented
|
||||
|
||||
macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上的 Node 24 曾在工作线程中执行 CJS 词法分析器时异常终止;进程边界能够隔离这一外部运行时故障,且无需从聚合流程中删除任何测试,而 Linux 与 Windows 仍使用开销更低的线程池。仓库自身引入的竞态均在相应的观测边界修复:开发构建产物的轮询逻辑每次发布重新扫描结果前,都会先暂存候选表、候选图和候选监视基线映射;构建产物缺失后会一直保持脏状态,直到成功计算内容哈希。PTY 就绪检测会在轮询检查前台进程组归属期间保留提示符候选项;常规静默时限也涵盖从交互式子进程继承而来的标记。真实 PTY fixture 会在运行时拼接同步标记,使就绪等待逻辑不会把交互式 shell 的输入回显误判为子进程已就绪。实时链接场景下的包管理器 e2e 会保留由工作流预先准备的 Corepack 主目录、pnpm 元数据缓存和 store 缓存,同时隔离其他包管理器的可变缓存,因此不会在安装前丢弃可复用的包管理器状态。
|
||||
|
||||
独立的 [Sandbox](../../../../.github/workflows/sandbox.yml) 工作流属于同一职责划分中的参考侧。其 bwrap、Landlock x64/arm64 与 Seatbelt 真实内核矩阵只在向 `master` 推送后运行。这四个作业仅用于诊断:它们既不是分支保护的必需项,也不会跨工作流计入 `all checks passed`。拉取请求 CI 仍通过常规的单元测试与覆盖率清单检查沙箱源码;宿主内核与 packed-install 验证在合并后报告结果。
|
||||
独立的 [Sandbox](../../../../.github/workflows/sandbox.yml) 工作流属于同一职责划分中的参考侧。其 bwrap、Landlock x64/arm64 与 Seatbelt 真实内核矩阵只在向 `master` 推送后运行。这四个作业仅用于诊断:它们既不是分支保护的必需项,也不会跨工作流计入 `all checks passed`。第五个作业 `unit-darwin` 在 `macos-latest` 上以自己的预算运行完整单元测试套件,让只在 darwin 上生效的单元测试预期保有平台判据,同时不让 Seatbelt 作业受制于单元测试套件的时长。拉取请求 CI 仍通过常规的单元测试与覆盖率清单检查沙箱源码;宿主内核与 packed-install 验证在合并后报告结果。
|
||||
|
||||
master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。ci-master 与 Sandbox 工作流把跨平台参考流程保留在 master 推送上。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。
|
||||
|
||||
@@ -35,7 +35,7 @@ master 分支的参考作业仅用于诊断,不参与拉取请求所要求的
|
||||
- **将每个超时值设为相应延迟目标**:不予采纳,因为调度波动会中止原本正确的执行,并使诊断回归所需的证据无法产生。
|
||||
- **仅信任并发执行的主门禁清单**:不予采纳,因为调度逻辑与校验逻辑共享实现假设;串行聚合流程是一项独立的完整性检查。
|
||||
- **在每个拉取请求上运行串行参考作业**:不予采纳,因为这些作业会重复完整的跨平台聚合流程,并为每项改动增加 macOS 工作;必需作业已经执行阻塞性的 Linux 和原生 Windows 检查,而其它原生作业提供完整的 Windows 结果。
|
||||
- **在每个拉取请求上运行真实内核 Sandbox 矩阵**:不予采纳,因为它的四个状态不参与分支保护,而重复安装、Landlock 构建以及为保持平台一致而运行的 macOS 单元测试会消耗运行器容量,却不会改变合并裁决。master 上的运行保留平台与已安装 launcher 的信号。
|
||||
- **在每个拉取请求上运行真实内核 Sandbox 矩阵**:不予采纳,因为它的状态不参与分支保护,而重复安装、Landlock 构建以及为保持平台一致而运行的 macOS 单元测试会消耗运行器容量,却不会改变合并裁决。master 上的运行保留平台与已安装 launcher 的信号。
|
||||
- **使用一个操作系统矩阵**:不予采纳,因为三个具名作业无需另一套选择机制,就能让参考流程的构成清晰可见。
|
||||
- **在大型运行器上运行串行参考流程**:不予采纳,因为当组织自有运行器池无法分配作业时,必需 CI 及其独立参考流程都必须仍可运行。
|
||||
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-09-26-web-conversation-clock-fixture-day.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.md
|
||||
/agent-note-web-conversation-clocks-on-the-fixture-day:
|
||||
en: 13b5d9d0bb3626af
|
||||
zh: 0bd01e1848284a35
|
||||
/agent-note-web-conversation-clocks-on-the-fixture-day/problem:
|
||||
en: 6f334488a6d3a28d
|
||||
zh: b33aed9b10d0cd84
|
||||
/agent-note-web-conversation-clocks-on-the-fixture-day/decision:
|
||||
en: ba875c82795d58e1
|
||||
zh: 13a7df6f8c4e0aa6
|
||||
/agent-note-web-conversation-clocks-on-the-fixture-day/alternatives-considered:
|
||||
en: 9c0a2afb1473c1d1
|
||||
zh: 36c9e72a70072f5e
|
||||
/agent-note-web-conversation-clocks-on-the-fixture-day/verification:
|
||||
en: 5031ca866fed24dd
|
||||
zh: c11f71af70c1f5ec
|
||||
/agent-note-web-conversation-clocks-on-the-fixture-day/consequences:
|
||||
en: c5cdbbd3b2889df7
|
||||
zh: 8458ef92443cf1a6
|
||||
@@ -0,0 +1,47 @@
|
||||
# Agent Note: Web conversation clocks on the fixture day
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-26-web-conversation-clock-fixture-day.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Run 36157253018 (job 108144715986, `node 24 / snapshots and artifacts`) failed four browser scenarios in one pass — [steering](../../../../apps/web/tests/steering.e2e.ts), [markdown-images](../../../../apps/web/tests/markdown-images.e2e.ts), [message-actions](../../../../apps/web/tests/message-actions.e2e.ts), and [reference-composer](../../../../apps/web/tests/reference-composer.e2e.ts) — with one aria difference: a message rendered `… and stop. 9/25 23:58` where its golden records `… and stop. {{clock}}`.
|
||||
|
||||
[`newEnglishPage`](../../../../apps/web/tests/support.ts) pins the browser to `Asia/Shanghai`, and [`formatMessageClock`](../../../../packages/client/ui-chat/src/client/chat/message-chrome.ts) prefixes `clock.md` / `clock.ymd` to the clock as soon as the message's local calendar day or year differs from the renderer's. These scenarios left both clocks on the wall clock, and their message times are stamped during the run: a live composer send by the Host, a seed whose fixture header carries `createdAt: 0` anchored at `Date.now() - 60_000` by `seedSession`. The failing job started at 15:54Z, six minutes before local midnight, so rows stamped before it were captured after it and rendered a date prefix; the next run of the same scenario passed minutes after midnight.
|
||||
|
||||
The lane already carried the remedy — [`WEB_FIXTURE_TIME`](../../../../apps/web/tests/support.ts), documented as the same-day anchor for seeded event times and the `Asia/Shanghai` browser clock, and used by seven other scenarios — but these four scenarios never adopted it.
|
||||
|
||||
## Decision
|
||||
|
||||
The four scenarios read the fixture day on both clocks.
|
||||
|
||||
Live-stamped scenarios pin the Host clock with `pinHostClock()`, a `Date.now` spy advancing from the anchor with real elapsed time, and the browser clock with [`pinBrowserClock(page)`](../../../../apps/web/tests/support.ts). Seeded scenarios anchor the seed through `seedSession`'s `createdAt` argument and pin the browser clock the same way.
|
||||
|
||||
The seed anchor is not only the rendered clock: the sidebar's relative ages are computed against the browser clock, so a pinned browser with a live-anchored seed reads `8mo` where the [message-actions](../../../../apps/web/tests/message-actions.e2e.ts) fork golden asserts `now` and `1min`.
|
||||
|
||||
`pinBrowserClock` re-applies one frozen instant from the anchor on an interval instead of leaving it at a single value, because product code reads two `Date.now()` values to decide how long a rendered value is held: the step process keeps its previous title for `PROCESS_TITLE_MINIMUM_MS` = 150 ms ([ChatGroupSeat](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx)) and the composer keymap keeps a composition guard 10 ms past `compositionend` ([keymap](../../../../packages/client/ui-conversation/src/client/input/editor/keymap.ts)). Both compute the remaining hold as `Date.now() - previousReading`, which a permanently frozen clock never advances, so the hold never elapses and the golden's settled text stays unrendered. CI run 36179748541 (job 108218939163) caught exactly that on the first revision that froze the clock: `Preparing questions` where the steering mid golden records `Waiting for your action · Ready to continue?`.
|
||||
|
||||
The steering mid capture waits for that settled question title before it captures, and the [whole-queue scenario](../../../../apps/web/tests/steering.e2e.ts) calls the queue gesture only after the running turn's Stop control appears, so Enter's queue decision always resolves against an agent that already reports busy.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Collapse the rendered date prefix in the aria normalizer.** Collapsing `9/25 23:58` to `{{clock}}` in `normalizeAria` fixes the four scenarios with a two-line change, and it was measured: it passed the four files and then failed `cordis-tool-round`, `stats-paged-history`, and `navigation-panes` in one `DSH_WEB_SNAPSHOT_WORKERS=16` lane run, because their goldens deliberately record the dated form (`9/1 {{clock}}` and the year form in the Timing pane), and `skill-tool-row` already tokenizes that form as `{{date}} {{clock}}`. The two renderings state different facts — this row is from another day, this row is from today — so collapsing them deletes assertions instead of stabilizing them.
|
||||
|
||||
**Leave the browser clock frozen.** Pinning one instant for the whole scenario is simpler and passed the four files, but it removes elapsed time from every two-reading hold in the page, which is how the `Preparing questions` capture above happened. Re-applying the anchor keeps the calendar pinned without changing how long a held value is held.
|
||||
|
||||
**Pin the clocks for the whole lane.** Pinning inside `launchWebScaffold` and `newEnglishPage` would cover every scenario at once, including the ones that fail this way in future. It loses on blast radius: 83 goldens carry a clock and session-tree goldens assert literal relative ages, so a lane-wide now moves every scenario away from its own session and fixture times simultaneously, and each would still need its own anchor to read the buckets it was recorded with.
|
||||
|
||||
**Keep scenarios clear of local midnight.** Scheduling or refusing runs near the boundary leaves the dependency in place and fails the run instead of the code under test. The [CI readiness and completion decision](2026-09-08-ci-readiness-and-completion.md) already rejects budgets and retries as substitutes for a controlled observation.
|
||||
|
||||
## Verification
|
||||
|
||||
Two negative controls pin one clock and leave the other on the wall clock, reproducing the recorded signature exactly: `steering` with the browser clock pinned and the Host clock live fails its two golden tests with `… and stop. 9/26 {{clock}}`, and `message-actions` with the browser clock pinned and a live-anchored seed fails with `9/26 {{clock}}` on every clock line plus `now` where the fork golden asserts `1min`. Pinning both clocks passes both files, and their goldens are unchanged.
|
||||
|
||||
The queue scenario's start race is measured, not inferred: with the clocks pinned and no busy observation it failed 10 times in 61 runs of `vitest run … -t "queues two messages"`, all at the same `2 queued messages` wait; `origin/master` without the pins failed 0 of 41; with the observation added, 20 of 20 passed.
|
||||
|
||||
CI run 36179748541 (job 108218939163) shows the calendar fix holding under the lane's own topology: the prompt line matches `{{clock}}` where run 36157253018 rendered `9/25 {{clock}}`.
|
||||
|
||||
## Consequences
|
||||
|
||||
The four scenarios render the same aria line whenever they run, and their goldens keep asserting a clock without a date prefix. The calendar fact is owned per scenario: a scenario that types messages, or seeds a fixture whose header carries `createdAt: 0`, must read the fixture day on both clocks, or it inherits the midnight exposure. Product clock rendering, the `Asia/Shanghai` browser timezone, every committed golden, and the assertions of the scenarios not listed here are unchanged.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Agent Note: Web conversation clocks on the fixture day
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-26-web-conversation-clock-fixture-day.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
运行 36157253018(作业 108144715986,`node 24 / snapshots and artifacts`)在一次执行中让四个浏览器场景失败——[steering](../../../../apps/web/tests/steering.e2e.ts)、[markdown-images](../../../../apps/web/tests/markdown-images.e2e.ts)、[message-actions](../../../../apps/web/tests/message-actions.e2e.ts) 与 [reference-composer](../../../../apps/web/tests/reference-composer.e2e.ts)——且是同一处 aria 差异:消息渲染为 `… and stop. 9/25 23:58`,而 golden 记录为 `… and stop. {{clock}}`。
|
||||
|
||||
[`newEnglishPage`](../../../../apps/web/tests/support.ts) 将浏览器时区固定为 `Asia/Shanghai`,而 [`formatMessageClock`](../../../../packages/client/ui-chat/src/client/chat/message-chrome.ts) 一旦消息的本地日历日或年份与渲染时不同,就会在时钟前拼接 `clock.md` / `clock.ymd`。这些场景把两个时钟都留在墙上时钟上,而它们的消息时间是在运行期盖章的:实时输入由 Host 盖章,fixture 头部为 `createdAt: 0` 的种子由 `seedSession` 以 `Date.now() - 60_000` 为锚点。失败的作业于 15:54Z 启动,距本地午夜还有六分钟,于是午夜前盖章的行在午夜后捕获并渲染出日期前缀;同一场景的下一次运行在午夜后数分钟通过。
|
||||
|
||||
通道本已具备对策——[`WEB_FIXTURE_TIME`](../../../../apps/web/tests/support.ts),其文档写明它是种子事件时间与 `Asia/Shanghai` 浏览器时钟的同日锚点,另有七个场景在使用它——但这四个场景从未采用。
|
||||
|
||||
## Decision
|
||||
|
||||
这四个场景让两个时钟都读取 fixture 日。
|
||||
|
||||
实时盖章的场景用 `pinHostClock()` 固定 Host 时钟(一个从锚点起按真实流逝时间推进的 `Date.now` spy),并用 [`pinBrowserClock(page)`](../../../../apps/web/tests/support.ts) 固定浏览器时钟。种子场景通过 `seedSession` 的 `createdAt` 参数锚定种子,并用同样方式固定浏览器时钟。
|
||||
|
||||
种子锚点不只影响渲染出的时钟:侧边栏的相对时间也以浏览器时钟为基准计算,因此固定浏览器却用实时锚点的种子会读出 `8mo`,而 [message-actions](../../../../apps/web/tests/message-actions.e2e.ts) 的 fork golden 断言的是 `now` 与 `1min`。
|
||||
|
||||
`pinBrowserClock` 按固定间隔从锚点重新施加同一个冻结时刻,而不是让时钟停在单一数值上,因为产品代码要靠读取两个 `Date.now()` 值来决定一个已渲染值要保持多久:步骤进程会把上一个标题保持 `PROCESS_TITLE_MINIMUM_MS` = 150 ms([ChatGroupSeat](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx)),输入框 keymap 会在 `compositionend` 后再保持 10 ms 的合成守卫([keymap](../../../../packages/client/ui-conversation/src/client/input/editor/keymap.ts))。两者都把剩余保持时间算作 `Date.now() - previousReading`,而永久冻结的时钟永远不会让它推进,于是保持期不会结束,golden 里那行稳定文案始终不出现。CI 运行 36179748541(作业 108218939163)在第一个冻结时钟的修订上正是抓到了这一点:steering 的 mid golden 记录的是 `Waiting for your action · Ready to continue?`,实际捕获到 `Preparing questions`。
|
||||
|
||||
steering 的 mid 捕获在截图前等待该稳定标题出现;[整队场景](../../../../apps/web/tests/steering.e2e.ts) 则只在运行中回合的停止控件出现后才做排队手势,因此 Enter 的排队判定总是相对一个已上报忙碌的 agent 解析。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在 aria normalizer 中折叠渲染出的日期前缀。** 在 `normalizeAria` 中把 `9/25 23:58` 折叠为 `{{clock}}` 只需两行即可修好这四个场景,且已实测:它让四个文件通过,随后在一次 `DSH_WEB_SNAPSHOT_WORKERS=16` 的通道运行中让 `cordis-tool-round`、`stats-paged-history` 与 `navigation-panes` 失败,因为这些 golden 有意记录带日期的形式(`9/1 {{clock}}`,以及 Timing 面板中的年份形式),而 `skill-tool-row` 早已把该形式 token 化为 `{{date}} {{clock}}`。两种渲染陈述的是不同事实——此行来自另一天,此行来自今天——因此折叠它们是在删除断言,而不是在稳定断言。
|
||||
|
||||
**让浏览器时钟保持冻结。** 整个场景固定单一时刻更简单,也能让四个文件通过,但它抽掉了页面中所有“读两次”保持逻辑的时间流逝,上面的 `Preparing questions` 捕获就是这样发生的。重新施加锚点既保持日历固定,又不改变已渲染值的保持时长。
|
||||
|
||||
**为整条通道固定时钟。** 在 `launchWebScaffold` 与 `newEnglishPage` 内固定时钟可以一次覆盖所有场景,包括将来以同样方式失败的场景。它因影响面而落选:83 个 golden 带有时钟,且会话树 golden 断言字面相对时间,因此通道级的 now 会同时把所有场景移离各自的会话与 fixture 时间,而每个场景仍需自己的锚点才能读出录制时的分桶。
|
||||
|
||||
**让场景避开本地午夜。** 在边界附近调度或拒绝运行会把依赖留在原处,并让运行而非被测代码失败。[CI 就绪与完成决策](2026-09-08-ci-readiness-and-completion.zh.md) 已经拒绝用预算或重试替代受控观察。
|
||||
|
||||
## Verification
|
||||
|
||||
两个负向对照各固定一个时钟、把另一个留在墙上时钟,精确复现了记录到的特征:`steering` 固定浏览器时钟而 Host 时钟实时时,两个 golden 用例以 `… and stop. 9/26 {{clock}}` 失败;`message-actions` 固定浏览器时钟但仍用实时锚点的种子时,每行时钟都以 `9/26 {{clock}}` 失败,且本应断言 `1min` 的 fork golden 读出 `now`。两个时钟都固定后两个文件均通过,且它们的 golden 未改动。
|
||||
|
||||
排队场景的启动竞态是实测而非推断:固定时钟且不等待忙碌状态时,`vitest run … -t "queues two messages"` 在 61 次中失败 10 次,全部停在同一个 `2 queued messages` 等待上;未加固定时钟的 `origin/master` 在 41 次中失败 0 次;加上该观察后 20 次全部通过。
|
||||
|
||||
CI 运行 36179748541(作业 108218939163)显示日历修复在通道自身的拓扑下成立:提示行匹配 `{{clock}}`,而运行 36157253018 渲染的是 `9/25 {{clock}}`。
|
||||
|
||||
## Consequences
|
||||
|
||||
这四个场景无论何时运行都渲染同一行 aria,其 golden 继续断言不带日期前缀的时钟。日历事实现在按场景归属:自行输入消息、或种子 fixture 头部为 `createdAt: 0` 的场景必须让两个时钟都读取 fixture 日,否则会继承午夜暴露。产品时钟渲染、`Asia/Shanghai` 浏览器时区、所有已提交的 golden,以及本说明未列出的场景的断言均不变。
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-09-27-observation-waits-on-observed-state.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.md
|
||||
/agent-note-windows-lane-observations-wait-on-the-state-they-read:
|
||||
en: 8949f81c9d585fc0
|
||||
zh: 34e1bdbca1cf2126
|
||||
/agent-note-windows-lane-observations-wait-on-the-state-they-read/problem:
|
||||
en: 270b015afc8de8ac
|
||||
zh: 65aeb1e270d19a27
|
||||
/agent-note-windows-lane-observations-wait-on-the-state-they-read/decision:
|
||||
en: 479cb25421102304
|
||||
zh: 9e23396728001e2c
|
||||
/agent-note-windows-lane-observations-wait-on-the-state-they-read/alternatives-considered:
|
||||
en: 00e59c561085e081
|
||||
zh: 4f6ac4dfc3259f58
|
||||
/agent-note-windows-lane-observations-wait-on-the-state-they-read/consequences:
|
||||
en: 13db32f9da19d87b
|
||||
zh: 48081fc7035dea45
|
||||
/agent-note-windows-lane-observations-wait-on-the-state-they-read/deferred:
|
||||
en: 18dc81f98375fb04
|
||||
zh: e30413cd3d201b80
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Windows-lane observations wait on the state they read
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-27-observation-waits-on-observed-state.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Two self-hosted Windows lane cases observed state through a wall-clock window instead of the state they assert on.
|
||||
|
||||
`packages/boot/plugin-manager/tests/operations.spec.ts` armed a 100 ms timer when the case started and read the profile's `run.json` when it fired, to observe the run record while the run was still in flight. The repaired installation it observes runs only after a refused installation, a `pnpm view` lookup, the restoration of the profile files, and a second `launch`, so the timer measured that whole prefix rather than the record write. Under the loaded runner the repair began after the deadline and the case failed with `expected null to deeply equal { pid: 4343, grouped: false }` (PR #4595 `windows node 24 / coverage`, job 108402945554, 2026-09-26; the case took 345 ms, and its sibling single-run cases, which need no such prefix, passed in 113 ms and 116 ms).
|
||||
|
||||
`packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` counts each send's settlement tier and requires the controlled-prompt fast path, but reported only the count, so a failure could not say which tier settled which send — the one fact that separates a degraded readiness path from a mis-sized window.
|
||||
|
||||
## Decision
|
||||
|
||||
`observingChild` stays in flight until the test has read the run record, so the read cannot race the run's end, and its bounded wait starts at the mocked launcher's hand-over instead of at test start: the recorded run is one atomic write that follows the launch, so the bound only has to cover that write. `RECORD_WAIT_MS` (2 s) is deliberately below the case budget, so a record that never appears reports as the assertion that names it rather than as a runner timeout. Both the repair-install case and the single-run case hand the child over through the same `spawned` hook.
|
||||
|
||||
The loader-composition settle-reason assertion carries `JSON.stringify(settleReasons)` as its failure message. The threshold and the assertions are unchanged.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Raise the fixed timer, or poll for the record from test start.** Rejected: both keep the window anchored before the operation reached the run it observes, so the bound has to cover the whole prefix and grows with whatever the runner does before the repair run — the same mistake at a larger size. Anchoring on the hand-over measures only the state transition being waited for.
|
||||
|
||||
**Wait for the record without a bound.** Rejected: a record the operation never writes would hang the case until the runner's own timeout and report nothing about the record.
|
||||
|
||||
**Record only the settlements that matter, or assert a subset of sends.** Rejected: it weakens what the suite pins — that no send falls back to the silence tier — to make the failure go away.
|
||||
|
||||
## Consequences
|
||||
|
||||
Both plugin-manager record cases now observe the record while the run is in flight on a loaded runner, and a genuinely missing record still fails the same assertion. The bounded wait costs 2 s only when the operation never writes the record.
|
||||
|
||||
## Deferred
|
||||
|
||||
The loader-composition failure of 2026-09-25/26 is an environment-sensitive readiness window, not a product logic error, and the product now owns a documented tolerance for it: the [prompt tail grace decision](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.md). On Windows `isStdinWaiting` returns false and `foregroundPgid` returns the shell pid, so `stdin_read` can only come from the controlled-prompt tail. A native Windows probe (Windows 11 ARM64 guest, 8 vCPU, with the readiness probe enabled) shows the fast path is deterministic when delivery is normal: every prompt render is the OSC marker alone in one pty chunk followed by a five-byte `dsh> ` chunk 2-28 ms later (56 renders), nothing printable follows any prompt, the output carries no terminal queries, and 21 runs (baseline, six CPU burners, emulated x64, four concurrent pty-heavy instances, and Windows PowerShell 5.1 as the shell) settled all seven sends on the prompt, six `stdin_read` plus the `exit` command's `session_exit`. Withholding each session's prompt tail for four seconds reproduces the reported failure exactly: `expected 4 to be greater than or equal to 6` with `["inferred_idle","stdin_read","stdin_read","stdin_read","stdin_read","session_exit","inferred_idle"]`, the degraded settles carrying `promptSeen` true, `promptTextSeen` false, an empty tail and `idleFor` 3301/3313 ms (exactly the configured bound), 22.1/21.9 s against 17.5/20.9 s in CI, and every output assertion still passing. The CI trigger itself was not reproduced locally, so this case keeps its assertion and supplies `promptTailGraceMs` in its composition: it measures the controlled-prompt path rather than the host's delivery timing, and it still fails when no prompt marker appears at all.
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Windows lane 的观测等待它所读取的状态
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-27-observation-waits-on-observed-state.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
自托管 Windows lane 的两个用例用墙钟窗口观测状态,而不是等待它们真正断言的那个状态。
|
||||
|
||||
`packages/boot/plugin-manager/tests/operations.spec.ts` 在用例开始时启动一个 100 毫秒定时器,并在它触发时读取 profile 的 `run.json`,以此在该 run 仍在进行时观测 run 记录。它要观测的「修复安装」只有在一次被拒绝的安装、一次 `pnpm view` 查询、profile 文件恢复以及第二次 `launch` 之后才会发生,因此该定时器度量的是这整段前缀,而不是记录写入本身。在负载较高的 runner 上修复安装晚于该截止时刻才开始,用例因而以 `expected null to deeply equal { pid: 4343, grouped: false }` 失败(PR #4595 的 `windows node 24 / coverage`,job 108402945554,2026-09-26;该用例耗时 345 毫秒,而相邻的单 run 用例不需要这段前缀,分别以 113 毫秒和 116 毫秒通过)。
|
||||
|
||||
`packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` 统计每次 send 的结算层级并要求受控提示符快路径,但只报告数量,失败时无法说明哪一层级结算了哪一次 send——而这正是区分「就绪路径退化」与「窗口取值不当」的唯一事实。
|
||||
|
||||
## Decision
|
||||
|
||||
`observingChild` 会保持该 run 在进行中,直到测试读到 run 记录,因此读取不可能与 run 结束竞争;其有界等待从被 mock 的启动器交接处开始,而不是从用例开始处开始:被记录的 run 只是紧随启动之后的一次原子写入,因此等待上界只需覆盖这次写入。`RECORD_WAIT_MS`(2 秒)刻意低于用例预算,于是一条始终没有出现的记录会以指名它的断言报告,而不是以 runner 超时报告。修复安装用例与单 run 用例都通过同一个 `spawned` 钩子交接子进程。
|
||||
|
||||
loader-composition 的结算层级断言把 `JSON.stringify(settleReasons)` 作为失败信息。阈值与断言本身不变。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**提高固定定时器的时长,或从用例开始处轮询记录。** 不采纳:两者都把窗口锚定在操作尚未到达它所观测的 run 之前,于是上界必须覆盖整段前缀,并随 runner 在修复 run 之前的行为一起增长——同样的错误,只是更大。锚定在交接处则只度量真正要等待的那个状态转换。
|
||||
|
||||
**无上界地等待记录。** 不采纳:若操作永不写入记录,用例会挂到 runner 自身的超时,且对记录本身不给出任何信息。
|
||||
|
||||
**只记录关键的那几次结算,或断言 send 的子集。** 不采纳:这会把该套件所固定的内容——任何 send 都不得回退到静默层——削弱成让失败消失。
|
||||
|
||||
## Consequences
|
||||
|
||||
在负载较高的 runner 上,两个插件管理器记录用例现在都能在该 run 仍在进行时观测到记录,而真正缺失的记录仍会以同一条断言失败。只有在操作始终不写记录时,这段有界等待才会付出 2 秒。
|
||||
|
||||
## Deferred
|
||||
|
||||
loader-composition 于 2026-09-25/26 的失败是一个对投递时序敏感的就绪窗口,而不是 product 逻辑错误;product 现在为它拥有一个文档化的容忍度:[提示符尾部宽限决策](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md)。在 Windows 上 `isStdinWaiting` 返回 false、`foregroundPgid` 返回 shell 自身的 pid,因此 `stdin_read` 只能来自受控提示符尾部。原生 Windows 探测(Windows 11 ARM64 guest,8 vCPU,启用就绪探测)表明只要投递正常,快路径就是确定的:每次提示符渲染都是 OSC 标记独占一个 pty 分块,紧随其后的下一个分块是 5 字节的 `dsh> `,间隔 2–28 毫秒(56 次渲染),任何提示符之后都没有可打印文本,输出中也没有终端查询;21 次运行(基线、6 个 CPU burner、模拟 x64、4 个并发 pty 实例、以及以 Windows PowerShell 5.1 作为 shell)全部七次 send 都按提示符结算——六次 `stdin_read` 加 `exit` 命令的 `session_exit`。把每个 session 的提示符尾部扣留 4 秒可以精确复现上报的失败:`expected 4 to be greater than or equal to 6`,`settleReasons` 为 `["inferred_idle","stdin_read","stdin_read","stdin_read","stdin_read","session_exit","inferred_idle"]`,退化的结算带着 `promptSeen` 为 true、`promptTextSeen` 为 false、空尾部与 `idleFor` 3301/3313 毫秒(正是配置的上界),耗时 22.1/21.9 秒对照 CI 的 17.5/20.9 秒,且所有输出断言仍然通过。CI 的触发条件本身未能在本地复现,因此该用例保留其断言并在组合中提供 `promptTailGraceMs`:它度量受控提示符路径而不是宿主机的投递时序,且在完全没有提示符标记时仍然失败。
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-09-27-plugin-manager-git-child-environment.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-27-plugin-manager-git-child-environment.md
|
||||
/agent-note-plugin-manager-git-children-isolate-the-host-s-command-line-configuration:
|
||||
en: ebb8f6a97dcc126a
|
||||
zh: 4f7fbb487136f218
|
||||
/agent-note-plugin-manager-git-children-isolate-the-host-s-command-line-configuration/problem:
|
||||
en: 67f7316995be5f48
|
||||
zh: fbb8dda3946ff1f3
|
||||
/agent-note-plugin-manager-git-children-isolate-the-host-s-command-line-configuration/decision:
|
||||
en: 7723360996b8b09e
|
||||
zh: 78d8dfa61b1711ec
|
||||
/agent-note-plugin-manager-git-children-isolate-the-host-s-command-line-configuration/alternatives-considered:
|
||||
en: 3f4e5b0bc9a8a961
|
||||
zh: 5f39ffd20e076914
|
||||
/agent-note-plugin-manager-git-children-isolate-the-host-s-command-line-configuration/verification:
|
||||
en: 7c8b5b8f4002c71a
|
||||
zh: 59493bd968ba34bf
|
||||
/agent-note-plugin-manager-git-children-isolate-the-host-s-command-line-configuration/consequences:
|
||||
en: b81437323c8ae6e2
|
||||
zh: 79f8d0926218ae2e
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Plugin-manager Git children isolate the host's command-line configuration
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-27-plugin-manager-git-child-environment.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The self-hosted Windows serial lane failed 13 cases in run 36227311113 (job 108363684565); 12 share one cause: [github-connection.spec.ts](../../../../packages/boot/plugin-manager/tests/github-connection.spec.ts) 9 (the six install-address forms and three proxy cases) and [manager.spec.ts](../../../../packages/boot/plugin-manager/tests/manager.spec.ts) 3 real-Git install cases. The host carried Git's indexed command-line configuration — `GIT_CONFIG_COUNT` with `GIT_CONFIG_KEY_0` / `GIT_CONFIG_VALUE_0`, the group Git hands to `git -c` calls and their hook children. The shared subprocess scrub (`scrubbedParentEnv`) removes credential-shaped names, so it stripped the key and kept the counter; Git reads the counter first, and every child died with `error: missing config key GIT_CONFIG_KEY_0` / `fatal: unable to parse command-line config` (exit 128) before opening any configuration file.
|
||||
|
||||
## Decision
|
||||
|
||||
Both spec files delete Git's command-line configuration group from their own process environment before they spawn Git, through [isolateGitCommandLineConfig](../../../../packages/boot/plugin-manager/tests/git-environment.ts). These specs judge profile behavior — profile Git configuration, real installs — rather than host state, so a host that exports the group no longer changes their outcome. The helper restores the removed entries after the file with `vi.stubEnv(name, undefined)` and `vi.unstubAllEnvs()`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Remove the group whole in `scrubbedParentEnv`.** The product-side fix treats the counter and its keys as one unit, but it changes shared subprocess behavior for every consumer, which the review directive for this change excludes. It is preserved on the branch `deferred/git-command-line-config-scrub-20260927` for a future product PR with its own justification.
|
||||
|
||||
**Pin `GIT_CONFIG_COUNT: '0'` at the plugin-manager call sites.** `packages/deliverables/workspace-changes/src/git.ts` does exactly that for its children, so the same two lines in `github-connection.ts` and `operations.ts` would cover this class. That is still a product behavior change, so it stays out of scope here.
|
||||
|
||||
**Clear the group in the lane's workflow.** The group reaches the job from the host image rather than from a step this repository owns, and other Git-spawning specs would stay exposed.
|
||||
|
||||
## Verification
|
||||
|
||||
`GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.bare GIT_CONFIG_VALUE_0=true pnpm exec vitest run packages/boot/plugin-manager/tests/github-connection.spec.ts packages/boot/plugin-manager/tests/manager.spec.ts` fails 12 cases at the parent revision with the lane's assertion set and passes 95/95 with the isolation in place.
|
||||
|
||||
## Consequences
|
||||
|
||||
The two specs pass on a host that exports Git command-line configuration. The product's treatment of such hosts is unchanged, so the same environment still breaks any other consumer of the shared scrub; a future product change to `scrubbedParentEnv` must revisit this note.
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# Agent Note: plugin-manager 的 Git 子进程隔离宿主机的命令行配置
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-27-plugin-manager-git-child-environment.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
自托管 Windows 串行 lane 在运行 36227311113(作业 108363684565)中失败 13 个用例,其中 12 个同一成因:[github-connection.spec.ts](../../../../packages/boot/plugin-manager/tests/github-connection.spec.ts) 9 个(六种安装地址形式与三个代理用例)与 [manager.spec.ts](../../../../packages/boot/plugin-manager/tests/manager.spec.ts) 3 个真实 Git 安装用例。该宿主机携带 Git 的索引式命令行配置——`GIT_CONFIG_COUNT` 加上 `GIT_CONFIG_KEY_0` / `GIT_CONFIG_VALUE_0`,即 Git 交给 `git -c` 调用及其 hook 子进程的那一组变量。共享的子进程清洗(`scrubbedParentEnv`)按凭据形态的名字移除变量,于是移除了键却保留了计数;Git 先读计数,每个子进程都在打开任何配置文件之前以 `error: missing config key GIT_CONFIG_KEY_0` / `fatal: unable to parse command-line config`(退出码 128)失败。
|
||||
|
||||
## Decision
|
||||
|
||||
这两个 spec 文件在派生 Git 之前,从自身进程环境里删除 Git 的命令行配置组,由 [isolateGitCommandLineConfig](../../../../packages/boot/plugin-manager/tests/git-environment.ts) 完成。这些 spec 判定的是 profile 行为——profile 的 Git 配置、真实安装——而不是宿主机状态,因此导出该组的宿主机不再改变它们的结果。辅助函数在文件结束后用 `vi.stubEnv(name, undefined)` 与 `vi.unstubAllEnvs()` 还原被移除的条目。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在 `scrubbedParentEnv` 中整体移除该组。** 产品侧修复把计数与它的键当作一个整体,但它会改变所有消费者共享的子进程行为,而本次改动的 review 指令排除了这一点。该修复保留在分支 `deferred/git-command-line-config-scrub-20260927`,留待将来带自身论证的产品 PR。
|
||||
|
||||
**在 plugin-manager 的调用点固定 `GIT_CONFIG_COUNT: '0'`。** `packages/deliverables/workspace-changes/src/git.ts` 正是这样对待自己的子进程,因此在 `github-connection.ts` 与 `operations.ts` 加同样两行即可覆盖这一类。但那仍属产品行为改动,故不在此范围内。
|
||||
|
||||
**在 lane 的工作流里清除该组。** 该组来自宿主机镜像,而不是本仓库拥有的某个步骤,且其他会派生 Git 的 spec 仍会暴露。
|
||||
|
||||
## Verification
|
||||
|
||||
`GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.bare GIT_CONFIG_VALUE_0=true pnpm exec vitest run packages/boot/plugin-manager/tests/github-connection.spec.ts packages/boot/plugin-manager/tests/manager.spec.ts` 在父修订上以 lane 的断言集合失败 12 个用例,加入隔离后 95/95 通过。
|
||||
|
||||
## Consequences
|
||||
|
||||
这两个 spec 在导出 Git 命令行配置的宿主机上通过。产品对此类宿主机的处理未变,因此同一环境仍会破坏共享清洗的其他消费者;将来若改动 `scrubbedParentEnv`,必须回头审视本记录。
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-09-27-web-lane-assertions-name-their-input-state.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.md
|
||||
/agent-note-web-lane-assertions-name-their-input-state:
|
||||
en: 6b04734ce8fbeb4a
|
||||
zh: 73db3bd3ac5985dc
|
||||
/agent-note-web-lane-assertions-name-their-input-state/problem:
|
||||
en: 12391509553e0152
|
||||
zh: 86d02884ae9c1b09
|
||||
/agent-note-web-lane-assertions-name-their-input-state/decision:
|
||||
en: c6a8e79707975429
|
||||
zh: 4fe1f6abd6fd4c1e
|
||||
/agent-note-web-lane-assertions-name-their-input-state/alternatives-considered:
|
||||
en: 6cd5940f55500c3d
|
||||
zh: ded68b931ed81e10
|
||||
/agent-note-web-lane-assertions-name-their-input-state/verification:
|
||||
en: 6d11abb7a60f81ae
|
||||
zh: 42bc43777a3b02b7
|
||||
/agent-note-web-lane-assertions-name-their-input-state/consequences:
|
||||
en: 2ecf084ca4df49c4
|
||||
zh: bbd8823fd0910609
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
# Agent Note: Web lane assertions name their input state
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-27-web-lane-assertions-name-their-input-state.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Three browser-lane scenarios captured an aria region while a state the golden records was only implied by whatever the pointer, the keyboard, or an in-flight Host read happened to be doing at that instant, so each capture could land on the other state.
|
||||
|
||||
[workspace-new-session-folding](../../../../apps/web/tests/workspace-new-session-folding.e2e.ts) captures two goldens of one sidebar. `sidebar.expected.md` records the Workspace row with its row-actions cell revealed, which [Rows.module.css](../../../../packages/client/ui-workspace/src/client/rows/Rows.module.css) does under `.projectRow:hover`; `first-batch.expected.md` records the sixth session that way under `.sessionRow:hover`, the first row the batch reveals. Both captures therefore depend on where the pointer sits, and the scenario's own "Show 11 more sessions" click reflows the list under a stationary pointer. The serial self-hosted master lane failed this scenario with both goldens' requirements unmet — `treeitem "{{workspace}}"` without the actions, and the golden's hovered row rendering its plain relative time — in every one of the nine failing runs observed between 2026-09-24 and 2026-09-27.
|
||||
|
||||
[turn-tail-actions](../../../../apps/web/tests/turn-tail-actions.e2e.ts) captures the Copy footer focused twice: `running.expected.md` records a `tooltip "Copy"` beside it, `settled.expected.md` records none. [Tooltip](../../../../packages/client/ui-primitives/src/Tooltip.tsx) ignores focus while the last input came from a pointer, and only a keydown clears that flag, so whether the first capture shows the bubble depends on whether the harness pressed a key after the scenario's last click.
|
||||
|
||||
[plugin-install-cancel](../../../../apps/web/tests/plugin-install-cancel.e2e.ts) read the enabled plugin card's `data-plugin-status` once after the highlighted card appeared. The store's `enableInstalled` marks the card and starts the directory reload the Host answers, so the highlight is observable before the reload commits the enabled bundle into the list: the status flipped `disabled` → `running` 61 ms after the highlight in three local runs, and the run that failed read it inside that window.
|
||||
|
||||
## Decision
|
||||
|
||||
Each capture names the state its golden records before it captures.
|
||||
|
||||
The folding scenario hovers the Workspace row before the sidebar capture and the first row the batch reveals before the first-batch capture, so both hovered rows are established rather than inherited from the layout around the Show-more click. The goldens, the folding quota, and the row rendering are unchanged; the scenario now performs the hover a reader of the golden would perform.
|
||||
|
||||
The turn-tail scenario clears the pointer flag with a key that moves nothing, focuses the Copy button, and waits for the tooltip the running golden records. The settled capture keeps the plain focus, which is what its golden records once the Stop click has left the pointer owning the last input.
|
||||
|
||||
The install-cancel scenario polls the card's status instead of sampling it, so the assertion observes the directory reload that the highlight precedes. The highlight stays the locator that finds the card, because the later `2400 ms` clock advance still asserts the card detaches with it.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Collapse the hovered and unhovered renderings in the aria normalizer.** The two renderings state different facts — one row is hovered, the others are not — and both goldens deliberately record the row-actions cell and the relative time. Collapsing them would delete the assertion rather than stabilize it, the same trade every lane golden that deliberately records a dated or stateful form already rejects.
|
||||
|
||||
**Deliver a blank event and keep the pointer where the click leaves it.** The button moves as the batch inserts, so the row under the pointer still follows layout: the folding negative control below reproduces the failure with the pointer inside the sidebar and no row hovered.
|
||||
|
||||
**Disable hover styling or the tooltip modality rule in the scenario.** Both are shipped rendering and interaction behavior the goldens are the evidence for; a scenario-local override would test a page the product does not serve.
|
||||
|
||||
**Raise the capture timeout or retry the comparison.** Neither establishes which state was captured; `captureStableAria` already waits for two equal consecutive snapshots, and it was satisfied by the wrong state — the limit the [CI completion decision](2026-09-08-ci-readiness-and-completion.md) and the [connection and compaction fixture preconditions](2026-09-12-connection-and-compaction-fixture-preconditions.md) already record.
|
||||
|
||||
## Verification
|
||||
|
||||
Two negative controls reproduce the recorded signatures with the shipped assertions. Hovering the New Session row instead of the Workspace row before the folding scenario's first capture fails `sidebar.expected.md` with `treeitem "{{workspace}}"` where the golden records `treeitem "{{workspace}} Workspace actions …"`. A bare mouse press with no key before the turn-tail scenario focuses the Copy button raises no bubble, and the running capture then fails with the missing `tooltip "Copy"` the serial lane recorded. Both controls pass after the change.
|
||||
|
||||
Measured locally, one file at a time: `DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/<file>.e2e.ts` → workspace-new-session-folding 15/15 before the change and 20/20 after, turn-tail-actions 8/8 before and 20/20 after, plugin-install-cancel 12/12 before and 20/20 after. The lane configuration `DSH_SNAPSHOT=replay DSH_WEB_SNAPSHOT_WORKERS=16 pnpm run test:web:ci` passes all three files in three consecutive local runs.
|
||||
|
||||
The install-cancel scenario carries a residual exposure this change does not remove and whose cause is not diagnosed: twice on `node 24 / snapshots and artifacts`, and once locally with the pre-change file on the same lane command, the highlighted card locator at `plugin-install-cancel.e2e.ts:217` stayed unmatched for 30 s after the enable click closed the dialog. The paused page clock rules out the highlight expiry timer, so the wait is not racing that; no local focused run or the three lane runs above reproduced it, and nothing here is known to hang the store's `load()`. The poll added by this change covers the status read after the card appears, not this locator.
|
||||
|
||||
The WebKit case the serial master lane also failed is not a scenario race and stays out of this change: `declared-reasoning.e2e.ts` launches Playwright's WebKit, the lane's step fetched that browser without its host libraries, and `browserType.launch` failed with "Host system is missing dependencies to run browsers". [ci.yml](../../../../.github/workflows/ci.yml) states the persistent VM image owns Playwright's Linux system packages and installs the dependency set only off the self-hosted pool, so repairing the image or the pool's ownership is a runner change, not a scenario one. Installing WebKit's libraries on a developer machine makes the file's 8 cases pass.
|
||||
|
||||
## Consequences
|
||||
|
||||
The three scenarios render the aria their goldens record whatever state the harness arrives in, and no golden, product surface, or timeout changed. The input state is now owned by each capture: a scenario that captures a hovered row, a focused Tooltip anchor, or a value published by a later Host read must establish that state itself. `document-preview.e2e.ts`'s spreadsheet-selection read has the same class of defect and stays with its own change.
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
# Agent Note: Web lane assertions name their input state
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-27-web-lane-assertions-name-their-input-state.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
浏览器通道的三个场景在捕获 aria 区域时,golden 记录的状态只是被指针、键盘或一次进行中的 Host 读取所隐含,因此每次捕获都可能落到另一种状态上。
|
||||
|
||||
[workspace-new-session-folding](../../../../apps/web/tests/workspace-new-session-folding.e2e.ts) 捕获同一侧边栏的两份 golden。`sidebar.expected.md` 记录的是 Workspace 行显示出行操作单元格的状态,该行为由 [Rows.module.css](../../../../packages/client/ui-workspace/src/client/rows/Rows.module.css) 的 `.projectRow:hover` 提供;`first-batch.expected.md` 则以 `.sessionRow:hover` 记录第六个会话行,即该批次展开出的第一行。两次捕获因此都取决于指针所在位置,而场景自身的 “Show 11 more sessions” 点击会在指针静止时重排列表。串行自托管 master 通道在这个场景上失败时,两份 golden 的要求都没有满足——`treeitem "{{workspace}}"` 不带操作按钮,golden 记录为悬停的那一行渲染出普通的相对时间——2026-09-24 至 2026-09-27 观察到的九次失败运行全部如此。
|
||||
|
||||
[turn-tail-actions](../../../../apps/web/tests/turn-tail-actions.e2e.ts) 两次捕获聚焦后的 Copy 页脚:`running.expected.md` 记录其旁有一个 `tooltip "Copy"`,`settled.expected.md` 则不记录。只要最后一次输入来自指针,[Tooltip](../../../../packages/client/ui-primitives/src/Tooltip.tsx) 就忽略聚焦,而只有 keydown 会清除该标记,因此第一次捕获是否出现气泡,取决于场景最后一次点击之后测试框架是否按过键。
|
||||
|
||||
[plugin-install-cancel](../../../../apps/web/tests/plugin-install-cancel.e2e.ts) 在带高亮的插件卡片出现后只读一次 `data-plugin-status`。store 的 `enableInstalled` 先标记卡片,再启动由 Host 应答的目录重载,因此高亮先于重载把已启用的 bundle 写入列表即可观察:三次本地运行中状态在 `disabled` → `running` 之间用了 61 ms,失败的那次运行正是在这个窗口内读取的。
|
||||
|
||||
## Decision
|
||||
|
||||
每次捕获都先确立其 golden 所记录的状态。
|
||||
|
||||
folding 场景在侧边栏捕获前悬停 Workspace 行,在 first-batch 捕获前悬停该批次展开出的第一行,两个被悬停的行都由场景确立,而不再继承 Show-more 点击周围的布局。golden、折叠配额与行渲染均不变;场景现在执行的是 golden 读者本会执行的那次悬停。
|
||||
|
||||
turn-tail 场景先用一个不移动焦点的按键清除指针标记,再聚焦 Copy 按钮,并等待 running golden 记录的气泡。settled 捕获保留普通聚焦,这正是 Stop 点击让指针拥有最后一次输入之后、其 golden 所记录的状态。
|
||||
|
||||
install-cancel 场景改为轮询卡片状态,而不再单次采样,因此该断言观察到的是高亮所先于的目录重载。高亮仍保留在定位卡片的 locator 中,因为随后推进 `2400 ms` 时钟的那步仍以它断言卡片会脱离。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在 aria normalizer 中折叠悬停与非悬停两种渲染。** 两者陈述不同事实——一行被悬停,其余没有被悬停——且两份 golden 都在有意记录行操作单元格与相对时间。折叠它们是在删除断言而非稳定断言,这与通道中每一份有意记录带日期或带状态形式的 golden 所拒绝的取舍相同。
|
||||
|
||||
**投递空白事件并让指针停留在点击后的位置。** 批次插入时按钮随之移动,指针下的行仍由布局决定:下面的 folding 负向对照在指针位于侧边栏内、没有任何行被悬停时复现了该失败。
|
||||
|
||||
**在场景内禁用悬停样式或 tooltip 的模态规则。** 两者都是 golden 所证明的已发布渲染与交互行为;场景级覆盖将测试一个产品并不提供的页面。
|
||||
|
||||
**提高捕获超时或重试比较。** 两者都不能确立实际捕获到的是哪个状态;`captureStableAria` 本已等待连续两次快照相等,而错误的状态同样能满足它——这一限度已由 [CI 就绪与完成决策](2026-09-08-ci-readiness-and-completion.zh.md) 与 [连接与压缩 fixture 前置条件](2026-09-12-connection-and-compaction-fixture-preconditions.zh.md) 记录。
|
||||
|
||||
## Verification
|
||||
|
||||
两个负向对照用已发布的断言复现了记录到的特征。folding 场景第一次捕获前改为悬停 New Session 行而非 Workspace 行时,`sidebar.expected.md` 失败:golden 记录 `treeitem "{{workspace}} Workspace actions …"`,实际得到 `treeitem "{{workspace}}"`。turn-tail 场景在聚焦 Copy 按钮前只做一次鼠标按下而不按键时不会出现气泡,running 捕获随后失败并缺少串行通道记录到的 `tooltip "Copy"`。两个对照在改动之后均通过。
|
||||
|
||||
本地按文件实测:`DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/<file>.e2e.ts` → workspace-new-session-folding 改动前 15/15、改动后 20/20,turn-tail-actions 改动前 8/8、改动后 20/20,plugin-install-cancel 改动前 12/12、改动后 20/20。通道配置 `DSH_SNAPSHOT=replay DSH_WEB_SNAPSHOT_WORKERS=16 pnpm run test:web:ci` 连续三次运行中三个文件全部通过。
|
||||
|
||||
install-cancel 场景仍带有本次改动未消除、且原因尚未定位的残余暴露:`node 24 / snapshots and artifacts` 上有两次、把该文件恢复到改动前版本后用同一通道命令运行的本地一次,都在启用点击关闭对话框后让 `plugin-install-cancel.e2e.ts:217` 的高亮卡片 locator 持续 30 s 未匹配。页面时钟已暂停,因此可排除高亮到期计时器,该等待并非与它竞争;上述三次通道运行与所有单文件运行都未复现,且尚无证据表明 store 的 `load()` 会挂起。本次改动新增的轮询覆盖的是卡片出现之后的状态读取,而不是这个 locator。
|
||||
|
||||
串行 master 通道同时失败的 WebKit 用例不是场景竞态,也不属于本次改动:`declared-reasoning.e2e.ts` 启动 Playwright 的 WebKit,通道的步骤只取浏览器本体而未安装宿主库,于是 `browserType.launch` 以 “Host system is missing dependencies to run browsers” 失败。[ci.yml](../../../../.github/workflows/ci.yml) 明确说明持久 VM 镜像拥有 Playwright 的 Linux 系统包,并且只在非自托管池随浏览器安装依赖集,因此修复镜像或该池的归属是 runner 侧的改动,而非场景改动。在开发者机器上安装 WebKit 依赖后该文件的 8 个用例全部通过。
|
||||
|
||||
## Consequences
|
||||
|
||||
三个场景无论测试框架以何种状态进入,都渲染其 golden 记录的 aria,且没有任何 golden、产品界面或超时被改动。输入状态现在由每次捕获自己拥有:凡是捕获悬停行、聚焦的 Tooltip 锚点,或由后续 Host 读取发布的值的场景,都必须自行确立该状态。`document-preview.e2e.ts` 的电子表格选区读取属于同一类缺陷,仍留给它自己的改动。
|
||||
@@ -1,7 +1,8 @@
|
||||
# Sandbox CI: the keyless real-kernel confinement proofs. This master-only
|
||||
# reference stays outside the pull-request verdict; rationale lives in
|
||||
# Sandbox CI: the keyless real-kernel confinement proofs, plus the darwin
|
||||
# unit-parity job (unit-darwin) that needs the matrix's macOS image. This
|
||||
# master-only reference stays outside the pull-request verdict; rationale lives in
|
||||
# .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md.
|
||||
# A separate workflow from ci.yml because the axis is different — these jobs
|
||||
# A separate workflow from ci.yml because the axis is different — the e2e jobs
|
||||
# fan out over OS×runner (kernel capabilities), not node versions. The Landlock
|
||||
# launcher is built from native/system on each Landlock leg and installed
|
||||
# from the same tarballs the main-repository release workflow publishes.
|
||||
@@ -96,14 +97,6 @@ jobs:
|
||||
pnpm --dir native/system run build:ts
|
||||
pnpm --dir native/system run build:native
|
||||
|
||||
# The unit suite runs on ubuntu in `checks`; this is the one darwin leg
|
||||
# in the workflow, so run it here too — the platform-dependent unit
|
||||
# expectations (Seatbelt path canonicalization: /tmp IS /private/tmp)
|
||||
# take their darwin branch only on this runner.
|
||||
- name: Unit tests (darwin parity)
|
||||
if: matrix.runner == 'seatbelt'
|
||||
run: pnpm run test
|
||||
|
||||
- name: Sandbox e2e (real kernel confinement, world-verified)
|
||||
# NO_COLOR: vitest force-enables ANSI color under GITHUB_ACTIONS even
|
||||
# without a TTY, which would thread escape codes through the summary
|
||||
@@ -139,3 +132,30 @@ jobs:
|
||||
echo "$out"
|
||||
[ "$status" -eq 0 ]
|
||||
echo "$out" | grep -qE 'Test Files[[:space:]]+1 passed \(1\)'
|
||||
|
||||
# The unit suite runs on ubuntu in ci.yml's Linux jobs; macos-latest is this
|
||||
# workflow's only darwin runner, so the platform-dependent unit expectations
|
||||
# (Seatbelt path canonicalization: /tmp IS /private/tmp — sandbox semantics,
|
||||
# hence this workflow rather than ci-master.yml) take their darwin branch only
|
||||
# here. A job of its own so the seatbelt e2e leg keeps the matrix budget. The
|
||||
# budget below is a completion ceiling, not a latency target: the full suite
|
||||
# takes 16 minutes on a fast macos-latest runner and was under half done at
|
||||
# 20 on the slowest observed.
|
||||
unit-darwin:
|
||||
name: unit tests (darwin parity, macos-latest)
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Install (immutable)
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Unit tests (darwin parity)
|
||||
run: pnpm run test
|
||||
@@ -275,7 +275,7 @@ it.skipIf(MODE === 'record').each(['en-US', 'zh-CN'])('fills the spreadsheet pan
|
||||
await expect.poll(async () => Math.round((await panel.boundingBox())!.width)).toBe(width)
|
||||
await expectExcelLayout(excel)
|
||||
expect(await meetingCanvas.evaluate(node => node.isConnected)).toBe(true)
|
||||
expect(await selection.innerText()).toBe('A1')
|
||||
await expect.poll(() => selection.innerText()).toBe('A1')
|
||||
await expect.poll(() => excel.locator('.fortune-sheettab-scroll').count()).toBe(width === 360 ? 2 : 0)
|
||||
}
|
||||
const gridOffset = await excel.locator('.luckysheet-scrollbar-x').evaluate(node => node.scrollLeft)
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
- switch "启用 自动化任务"
|
||||
- listitem:
|
||||
- button "查看 语音输入": 语音输入
|
||||
- text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖
|
||||
- text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。
|
||||
- switch "启用 语音输入"
|
||||
- listitem:
|
||||
- button "查看 终端": 终端
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
- button "关闭"
|
||||
- paragraph: 输入插件的包名、GitHub 仓库地址或本地目录路径。
|
||||
- textbox "包名或地址":
|
||||
- /placeholder: 例如 @deepseek-ai/dsh-subagent-codex
|
||||
- /placeholder: 例如 dsh-plugin-whale-pet
|
||||
- text: slow-package
|
||||
- button "插件安装引导和示例"
|
||||
- button "安装源 默认安装源"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
- listitem:
|
||||
- button "查看 slow-package": slow-package
|
||||
- switch "启用 slow-package" [checked]
|
||||
|
||||
[
|
||||
{
|
||||
"colorScheme": "light",
|
||||
"reducedMotion": "no-preference",
|
||||
"boxShadow": "oklab(0.589542 -0.0217781 -0.177995 / 0.4) 0px 0px 0px 2px",
|
||||
"animated": true
|
||||
},
|
||||
{
|
||||
"colorScheme": "light",
|
||||
"reducedMotion": "reduce",
|
||||
"boxShadow": "color(srgb 0.254902 0.462745 0.901961 / 0.4) 0px 0px 0px 2px",
|
||||
"animated": false
|
||||
},
|
||||
{
|
||||
"colorScheme": "dark",
|
||||
"reducedMotion": "no-preference",
|
||||
"boxShadow": "oklab(0.739592 -0.02038 -0.132631 / 0.4) 0px 0px 0px 2px",
|
||||
"animated": true
|
||||
},
|
||||
{
|
||||
"colorScheme": "dark",
|
||||
"reducedMotion": "reduce",
|
||||
"boxShadow": "color(srgb 0.478431 0.666667 1 / 0.4) 0px 0px 0px 2px",
|
||||
"animated": false
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,66 @@
|
||||
- radio "自定义地址" [checked]
|
||||
- text: 自定义地址
|
||||
- textbox "自定义地址" [invalid]:
|
||||
- /placeholder: https://npm.example.com/
|
||||
- text: invalid-registry
|
||||
- alert: 请输入以 http:// 或 https:// 开头的地址
|
||||
- text: 填写内网或私有 npm 源地址,以 http:// 或 https:// 开头。若为需要登录的源,请把凭据放在本机的 ~/.npmrc 里。
|
||||
|
||||
{
|
||||
"registry": [
|
||||
{
|
||||
"colorScheme": "light",
|
||||
"focused": {
|
||||
"outline": "none",
|
||||
"boxShadow": "rgb(65, 118, 230) 0px 0px 0px 0.5px inset",
|
||||
"borderColor": "rgb(236, 19, 19)"
|
||||
},
|
||||
"blurred": {
|
||||
"outline": "none",
|
||||
"boxShadow": "none",
|
||||
"borderColor": "rgb(236, 19, 19)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"colorScheme": "dark",
|
||||
"focused": {
|
||||
"outline": "none",
|
||||
"boxShadow": "rgb(122, 170, 255) 0px 0px 0px 0.5px inset",
|
||||
"borderColor": "rgb(242, 90, 90)"
|
||||
},
|
||||
"blurred": {
|
||||
"outline": "none",
|
||||
"boxShadow": "none",
|
||||
"borderColor": "rgb(242, 90, 90)"
|
||||
}
|
||||
}
|
||||
],
|
||||
"packageName": [
|
||||
{
|
||||
"colorScheme": "light",
|
||||
"focused": {
|
||||
"outline": "none",
|
||||
"boxShadow": "rgb(65, 118, 230) 0px 0px 0px 0.5px inset",
|
||||
"borderColor": "rgb(236, 19, 19)"
|
||||
},
|
||||
"blurred": {
|
||||
"outline": "none",
|
||||
"boxShadow": "none",
|
||||
"borderColor": "rgb(236, 19, 19)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"colorScheme": "dark",
|
||||
"focused": {
|
||||
"outline": "none",
|
||||
"boxShadow": "rgb(122, 170, 255) 0px 0px 0px 0.5px inset",
|
||||
"borderColor": "rgb(242, 90, 90)"
|
||||
},
|
||||
"blurred": {
|
||||
"outline": "none",
|
||||
"boxShadow": "none",
|
||||
"borderColor": "rgb(242, 90, 90)"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,14 +2,14 @@
|
||||
- heading "添加插件" [level=2]
|
||||
- button "关闭"
|
||||
- textbox "插件包名":
|
||||
- /placeholder: 例如 @deepseek-ai/dsh-subagent-codex
|
||||
- /placeholder: 例如 dsh-plugin-whale-pet
|
||||
- button "收起引导" [expanded]
|
||||
- button "安装源 中国大陆镜像源"
|
||||
- list:
|
||||
- listitem:
|
||||
- text: 包名 插件包名即 npm 包名(如 dsh-xxx 或 @作者/插件名),社区插件的 README 安装命令中 dsh plugin add 或 pnpm add 之后的部分。 示例:
|
||||
- code: "@deepseek-ai/dsh-subagent-codex"
|
||||
- button "填入示例 @deepseek-ai/dsh-subagent-codex": 填入示例
|
||||
- code: dsh-plugin-whale-pet
|
||||
- button "填入示例 dsh-plugin-whale-pet": 填入示例
|
||||
- listitem:
|
||||
- text: GitHub 仓库地址 插件在 GitHub 上的开源仓库地址,也支持其他 Git 仓库。 示例:
|
||||
- code: https://github.com/author/dsh-plugin
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
- heading "添加插件" [level=2]
|
||||
- button "关闭"
|
||||
- textbox "插件包名":
|
||||
- /placeholder: 例如 @deepseek-ai/dsh-subagent-codex
|
||||
- /placeholder: 例如 dsh-plugin-whale-pet
|
||||
- button "插件安装引导和示例"
|
||||
- button "安装源 中国大陆镜像源"
|
||||
- button "安装" [disabled]
|
||||
@@ -1,10 +1,10 @@
|
||||
- group "从哪个 npm 源下载插件":
|
||||
- radio "npm 官方源(registry.npmjs.org)"
|
||||
- text: npm 官方源(registry.npmjs.org)
|
||||
- radio "中国大陆镜像源(registry.npmmirror.com)" [checked]
|
||||
- text: 中国大陆镜像源(registry.npmmirror.com)
|
||||
- radio "npm 官方源 registry.npmjs.org"
|
||||
- text: npm 官方源 registry.npmjs.org
|
||||
- radio "中国大陆镜像源 registry.npmmirror.com" [checked]
|
||||
- text: 中国大陆镜像源 registry.npmmirror.com
|
||||
- radio "自定义地址"
|
||||
- text: 自定义地址
|
||||
- textbox "自定义地址" [disabled]:
|
||||
- textbox "自定义地址":
|
||||
- /placeholder: https://npm.example.com/
|
||||
- text: 公司内网或私有 npm 源;需要登录的源,把凭据放在本机的 ~/.npmrc 里
|
||||
- text: 填写内网或私有 npm 源地址,以 http:// 或 https:// 开头。若为需要登录的源,请把凭据放在本机的 ~/.npmrc 里。
|
||||
@@ -1,10 +1,10 @@
|
||||
- group "从哪个 npm 源下载插件":
|
||||
- radio "npm 官方源(registry.npmjs.org)" [checked]
|
||||
- text: npm 官方源(registry.npmjs.org)
|
||||
- radio "中国大陆镜像源(registry.npmmirror.com)"
|
||||
- text: 中国大陆镜像源(registry.npmmirror.com)
|
||||
- radio "npm 官方源 registry.npmjs.org" [checked]
|
||||
- text: npm 官方源 registry.npmjs.org
|
||||
- radio "中国大陆镜像源 registry.npmmirror.com"
|
||||
- text: 中国大陆镜像源 registry.npmmirror.com
|
||||
- radio "自定义地址"
|
||||
- text: 自定义地址
|
||||
- textbox "自定义地址" [disabled]:
|
||||
- textbox "自定义地址":
|
||||
- /placeholder: https://npm.example.com/
|
||||
- text: 公司内网或私有 npm 源;需要登录的源,把凭据放在本机的 ~/.npmrc 里
|
||||
- text: 填写内网或私有 npm 源地址,以 http:// 或 https:// 开头。若为需要登录的源,请把凭据放在本机的 ~/.npmrc 里。
|
||||
@@ -1,10 +1,10 @@
|
||||
- group "从哪个 npm 源下载插件":
|
||||
- radio "npm 官方源(registry.npmjs.org)"
|
||||
- text: npm 官方源(registry.npmjs.org)
|
||||
- radio "中国大陆镜像源(registry.npmmirror.com)" [checked]
|
||||
- text: 中国大陆镜像源(registry.npmmirror.com)
|
||||
- radio "npm 官方源 registry.npmjs.org"
|
||||
- text: npm 官方源 registry.npmjs.org
|
||||
- radio "中国大陆镜像源 registry.npmmirror.com" [checked]
|
||||
- text: 中国大陆镜像源 registry.npmmirror.com
|
||||
- radio "自定义地址"
|
||||
- text: 自定义地址
|
||||
- textbox "自定义地址" [disabled]:
|
||||
- textbox "自定义地址":
|
||||
- /placeholder: https://npm.example.com/
|
||||
- text: 公司内网或私有 npm 源;需要登录的源,把凭据放在本机的 ~/.npmrc 里
|
||||
- text: 填写内网或私有 npm 源地址,以 http:// 或 https:// 开头。若为需要登录的源,请把凭据放在本机的 ~/.npmrc 里。
|
||||
@@ -0,0 +1,62 @@
|
||||
Synthetic browser KeyboardEvents, not an OS input-method test.
|
||||
package / isComposing=true: inspect=0, install=0; editable; value retained
|
||||
- textbox "包名或地址":
|
||||
- /placeholder: 例如 dsh-plugin-whale-pet
|
||||
- text: ime-confirm-package
|
||||
package / Safari isComposing=false, keyCode=229: inspect=0, install=0; editable; value retained
|
||||
- textbox "包名或地址":
|
||||
- /placeholder: 例如 dsh-plugin-whale-pet
|
||||
- text: ime-confirm-package
|
||||
package / compositionstart + unmarked Enter: inspect=0, install=0; editable; value retained
|
||||
- textbox "包名或地址":
|
||||
- /placeholder: 例如 dsh-plugin-whale-pet
|
||||
- text: ime-confirm-package
|
||||
package / compositionend + immediate unmarked Enter: inspect=0, install=0; editable; value retained
|
||||
- textbox "包名或地址":
|
||||
- /placeholder: 例如 dsh-plugin-whale-pet
|
||||
- text: ime-confirm-package
|
||||
package / compositionend + 9ms unmarked Enter: inspect=0, install=0; editable; value retained
|
||||
- textbox "包名或地址":
|
||||
- /placeholder: 例如 dsh-plugin-whale-pet
|
||||
- text: ime-confirm-package
|
||||
package / plain Enter: inspect=1, install=1
|
||||
- dialog "插件安装失败":
|
||||
- button "返回编辑": 编辑
|
||||
- button "关闭"
|
||||
- alert: 插件安装失败
|
||||
- paragraph: "IME fixture: no package was installed"
|
||||
- paragraph: ime-confirm-package
|
||||
- paragraph: 版本 1.0.0
|
||||
- button "查看安装详情"
|
||||
- button "重试"
|
||||
custom registry / isComposing=true: inspect=0, install=0; editable; value retained
|
||||
- textbox "自定义地址":
|
||||
- /placeholder: https://npm.example.com/
|
||||
- text: https://registry.example.test/
|
||||
custom registry / Safari isComposing=false, keyCode=229: inspect=0, install=0; editable; value retained
|
||||
- textbox "自定义地址":
|
||||
- /placeholder: https://npm.example.com/
|
||||
- text: https://registry.example.test/
|
||||
custom registry / compositionstart + unmarked Enter: inspect=0, install=0; editable; value retained
|
||||
- textbox "自定义地址":
|
||||
- /placeholder: https://npm.example.com/
|
||||
- text: https://registry.example.test/
|
||||
custom registry / compositionend + immediate unmarked Enter: inspect=0, install=0; editable; value retained
|
||||
- textbox "自定义地址":
|
||||
- /placeholder: https://npm.example.com/
|
||||
- text: https://registry.example.test/
|
||||
custom registry / compositionend + 9ms unmarked Enter: inspect=0, install=0; editable; value retained
|
||||
- textbox "自定义地址":
|
||||
- /placeholder: https://npm.example.com/
|
||||
- text: https://registry.example.test/
|
||||
custom registry / plain Enter: inspect=1, install=1
|
||||
- dialog "插件安装失败":
|
||||
- button "返回编辑": 编辑
|
||||
- button "关闭"
|
||||
- alert: 插件安装失败
|
||||
- paragraph: "IME fixture: no package was installed"
|
||||
- paragraph: ime-confirm-registry
|
||||
- paragraph: 版本 1.0.0
|
||||
- button "查看安装详情"
|
||||
- button "重试"
|
||||
Profile manifest unchanged; controlled Host result performed no package installation.
|
||||
@@ -19,7 +19,7 @@
|
||||
- switch "启用 自动化任务"
|
||||
- listitem:
|
||||
- button "查看 语音输入": 语音输入
|
||||
- text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖
|
||||
- text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。
|
||||
- switch "启用 语音输入"
|
||||
- listitem:
|
||||
- button "查看 终端": 终端
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
- heading "插件" [level=1]
|
||||
- paragraph: 添加和管理插件
|
||||
- button "刷新" [disabled]
|
||||
- button "添加插件" [disabled]
|
||||
- status "正在读取插件…"
|
||||
|
||||
1680px: 4 rows; page/group headers, rows, icons, text lines and blank action spaces align within 0.1px
|
||||
1000px: 4 rows; page/group headers, rows, icons, text lines and blank action spaces align within 0.1px
|
||||
Loading: header actions disabled; action spaces are empty 36×20 areas, with no switch placeholders
|
||||
Loaded: skeleton removed; header actions enabled
|
||||
Motion: 13 opacity-only pulses, 2000ms; reduced motion stops all pulses
|
||||
@@ -19,7 +19,7 @@
|
||||
- switch "启用 自动化任务"
|
||||
- listitem:
|
||||
- button "查看 语音输入": 语音输入
|
||||
- text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖
|
||||
- text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。
|
||||
- switch "启用 语音输入"
|
||||
- listitem:
|
||||
- button "查看 终端": 终端
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
Hover at 499ms: 0 tooltips
|
||||
Hover at 500ms:
|
||||
- tooltip "刷新"
|
||||
Focus at 499ms: 0 tooltips
|
||||
Focus at 500ms:
|
||||
- tooltip "刷新"
|
||||
failure: pending
|
||||
- button "刷新" [disabled]
|
||||
{"busy":"true","spinners":1,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":[]}
|
||||
failure: settled
|
||||
- button "刷新"
|
||||
{"busy":"false","spinners":0,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]}
|
||||
@@ -1 +1,3 @@
|
||||
- text: Automation task
|
||||
|
||||
- paragraph: "Reminder: Check the deployment log."
|
||||
@@ -8,13 +8,13 @@
|
||||
- tab "Trajectory"
|
||||
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
|
||||
- button "Copy"
|
||||
- status: Deep diving...
|
||||
- button "Deep diving for {{duration}}" [disabled] [expanded]
|
||||
- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that."
|
||||
- text: "Interjection: include the word BANANA in your final reply."
|
||||
- button "Copy"
|
||||
- text: "Interjection: include the word ORANGE in your final reply."
|
||||
- button "Copy"
|
||||
- status: Deep diving...
|
||||
- text: Deep diving for {{duration}}...
|
||||
- textbox "Message or run a task, / commands, @ files or sessions"
|
||||
- button "Add files or run commands"
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
|
||||
@@ -145,7 +145,7 @@ it.skipIf(webSnapshotMode() === 'record').each(['inbox-first', 'transcript-first
|
||||
if (order === 'transcript-first') {
|
||||
await expect.poll(() => gate.turnStartDelivery()).toBeDefined()
|
||||
gate.releaseThrough(gate.turnStartDelivery()!)
|
||||
await page.locator('[data-turn-process]').last().filter({ hasText: 'Deep diving' }).waitFor()
|
||||
await page.locator('[data-chat-flow-kind="turn-process"]').last().waitFor({ state: 'attached' })
|
||||
await expect.poll(() => placement(page)).toMatchObject({ echo: 1, dock: 0, durable: 0 })
|
||||
trace.push({ phase: 'turn-start', ...await placement(page) })
|
||||
await expect.poll(() => gate.admissionDelivery()).toBeDefined()
|
||||
@@ -167,7 +167,7 @@ it.skipIf(webSnapshotMode() === 'record').each(['inbox-first', 'transcript-first
|
||||
if (order === 'inbox-first') {
|
||||
await expect.poll(() => gate.turnStartDelivery()).toBeDefined()
|
||||
gate.releaseThrough(gate.turnStartDelivery()!)
|
||||
await page.locator('[data-turn-process]').last().filter({ hasText: 'Deep diving' }).waitFor()
|
||||
await page.locator('[data-chat-flow-kind="turn-process"]').last().waitFor({ state: 'attached' })
|
||||
await expect.poll(() => placement(page)).toMatchObject({ echo: 1, dock: 0, durable: 0 })
|
||||
trace.push({ phase: 'turn-start', ...await placement(page) })
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
|
||||
// hang (prefix chunks delivered to the loop) before the stop click.
|
||||
await expect.poll(() => existsSync(marker), { timeout: 15_000 }).toBe(true)
|
||||
await expect.poll(
|
||||
() => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(),
|
||||
() => page.getByRole('status').filter({ hasText: 'Deep diving' }).isVisible(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(true)
|
||||
await page.locator('[data-streaming="true"]')
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { newEnglishPage, pinBrowserClock, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/markdown-images', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./expected/markdown-images/ui.expected.md', import.meta.url))
|
||||
@@ -166,6 +166,7 @@ describe('web e2e: Markdown image rendering', () => {
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let unpinBrowserClock: (() => void) | undefined
|
||||
const mediaResponses = new Map<string, number>()
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -186,9 +187,11 @@ describe('web e2e: Markdown image rendering', () => {
|
||||
const outsidePath = join(scaffold.persistenceRoot, 'outside.png')
|
||||
await writeFile(outsidePath, PNG)
|
||||
await writeFile(join(scaffold.workspaceCwd, 'active.html'), '<p>File preview</p><script>document.body.dataset.scriptRan = "yes"</script>')
|
||||
await seedSession(scaffold, markdownImageFixture(imageOrigin.url, outsidePath), SEED_ID)
|
||||
await seedSession(scaffold, markdownImageFixture(imageOrigin.url, outsidePath), SEED_ID, undefined,
|
||||
{ createdAt: WEB_FIXTURE_TIME - 60_000 })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
unpinBrowserClock = await pinBrowserClock(page)
|
||||
tripwire = watchConsole(page)
|
||||
page.on('response', (response) => {
|
||||
const url = new URL(response.url())
|
||||
@@ -201,6 +204,7 @@ describe('web e2e: Markdown image rendering', () => {
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
unpinBrowserClock?.()
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
if (imageOrigin !== undefined) await stopServer(imageOrigin.server)
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, parseSeedFixture, renderSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettings, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { openSettings, newEnglishPage, pinBrowserClock, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/message-actions', import.meta.url))
|
||||
// Borrowed read-only: this scenario needs any settled user+assistant pair, not
|
||||
@@ -189,6 +189,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let unpinBrowserClock: (() => void) | undefined
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
@@ -201,15 +202,21 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
expect(parseSeedFixture(raw).events.flatMap(event => event.type === 'request/header'
|
||||
? [event.data.reason]
|
||||
: []), 'adapted seed must carry an unchanged resume header').toEqual(['initial', 'resume'])
|
||||
await seedSession(scaffold, raw, SEED_ID)
|
||||
// The fixture carries no times of its own, so the seed anchors them at
|
||||
// `Date.now() - 60_000` unless the scenario names the shared fixture day.
|
||||
// 90 s keeps the earliest rows in the `1min` bucket and the later ones,
|
||||
// carried by their stream spans, inside `now` — what the fork golden records.
|
||||
await seedSession(scaffold, raw, SEED_ID, undefined, { createdAt: WEB_FIXTURE_TIME - 90_000 })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
unpinBrowserClock = await pinBrowserClock(page)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
unpinBrowserClock?.()
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
@@ -388,6 +388,10 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
await dialog.getByRole('button', { name: '保存', exact: true }).click()
|
||||
await types.waitFor({ state: 'detached' })
|
||||
await expect(scaffold.ctx.llm.resolveModelInfo('openai', 'gpt-6-astra')).resolves.toMatchObject({ inputModalities: ['text'] })
|
||||
// The saved notice confirms the browser directory refresh after the editor closes.
|
||||
// Reopening before it copies the previous selection into a new draft, which
|
||||
// checkbox polling cannot correct.
|
||||
await dialog.getByText('已保存 openai。', { exact: true }).waitFor({ timeout: 10_000 })
|
||||
await edit.click()
|
||||
await dialog.getByText('自定义设置').click()
|
||||
await dialog.getByRole('button', { name: '模型选项 1' }).click()
|
||||
|
||||
@@ -4,12 +4,60 @@ import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium } from 'playwright'
|
||||
import { chromium, type Locator, type Page } from 'playwright'
|
||||
import { expect, it } from 'vitest'
|
||||
import { launchWebScaffold, captureStableAria, compareOrRefreshGolden, webSnapshotMode, watchConsole, type WebScaffold } from './scaffold.ts'
|
||||
import { ZH_BROWSER_LOCALE } from './support.ts'
|
||||
|
||||
it('cancels installation through the UI, restores files, and offers the spec again', async () => {
|
||||
async function invalidInputStyles(page: Page, input: Locator) {
|
||||
const styles = []
|
||||
try {
|
||||
for (const colorScheme of ['light', 'dark'] as const) {
|
||||
await page.emulateMedia({ colorScheme })
|
||||
await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe(colorScheme === 'dark' ? '' : null)
|
||||
await input.focus()
|
||||
await expect.poll(() => input.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
expect(await input.getAttribute('aria-invalid')).toBe('true')
|
||||
const { focused, focusRingColor, errorBorderColor } = await input.evaluate((element) => {
|
||||
const style = getComputedStyle(element)
|
||||
const probe = document.createElement('span')
|
||||
probe.style.display = 'none'
|
||||
probe.style.color = style.getPropertyValue('--dsw-focus-ring-color').trim() || style.getPropertyValue('--dsw-alias-state-business-primary')
|
||||
element.after(probe)
|
||||
try {
|
||||
const focusRingColor = getComputedStyle(probe).color
|
||||
probe.style.color = style.getPropertyValue('--dsw-alias-state-error-primary')
|
||||
return {
|
||||
focused: { outline: style.outlineStyle, boxShadow: style.boxShadow, borderColor: style.borderColor },
|
||||
focusRingColor,
|
||||
errorBorderColor: getComputedStyle(probe).color,
|
||||
}
|
||||
} finally {
|
||||
probe.remove()
|
||||
}
|
||||
})
|
||||
expect(focused.outline).toBe('none')
|
||||
expect(focused.boxShadow).toContain('inset')
|
||||
expect(focused.boxShadow).toContain('0px 0px 0px 0.5px')
|
||||
expect(focused.boxShadow).toContain(focusRingColor)
|
||||
expect(focused.borderColor).toBe(errorBorderColor)
|
||||
expect(focusRingColor).not.toBe(errorBorderColor)
|
||||
await input.evaluate((element) => { (element as HTMLInputElement).blur() })
|
||||
await expect.poll(() => input.evaluate(element => document.activeElement === element)).toBe(false)
|
||||
const blurred = await input.evaluate((element) => {
|
||||
const style = getComputedStyle(element)
|
||||
return { outline: style.outlineStyle, boxShadow: style.boxShadow, borderColor: style.borderColor }
|
||||
})
|
||||
expect(blurred).toEqual({ outline: 'none', boxShadow: 'none', borderColor: focused.borderColor })
|
||||
styles.push({ colorScheme, focused, blurred })
|
||||
}
|
||||
return styles
|
||||
} finally {
|
||||
await page.emulateMedia({ colorScheme: null })
|
||||
}
|
||||
}
|
||||
|
||||
it('cancels installation, retries and highlights the enabled plugin at 40% alpha, and recovers unknown results', async () => {
|
||||
const scratch = await mkdtemp(join(tmpdir(), 'dsh-install-cancel-'))
|
||||
const overlay = join(scratch, 'cordis.patch.yml')
|
||||
await writeFile(overlay, `- id: plugin-manager\n config: ${JSON.stringify({ pnpmCommand: process.execPath })}\n`)
|
||||
@@ -39,6 +87,7 @@ it('cancels installation through the UI, restores files, and offers the spec aga
|
||||
`)
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
const tripwire = watchConsole(page)
|
||||
await page.clock.install()
|
||||
await page.goto(scaffold.authenticatedUrl)
|
||||
await page.waitForSelector('[class*="frame"]')
|
||||
if (await page.getByRole('dialog', { name: '设置' }).count() > 0) await page.keyboard.press('Escape')
|
||||
@@ -48,6 +97,29 @@ it('cancels installation through the UI, restores files, and offers the spec aga
|
||||
// The dialog is named after its current screen, so it is found by role alone.
|
||||
const dialog = page.getByRole('dialog')
|
||||
await dialog.getByRole('textbox').fill('slow-package')
|
||||
await dialog.getByRole('button', { name: /^安装源/ }).click()
|
||||
const registryMenu = page.locator('[data-install-registry]')
|
||||
const customAddress = registryMenu.getByRole('textbox', { name: '自定义地址', exact: true })
|
||||
await customAddress.fill('invalid-registry')
|
||||
await page.keyboard.press('Escape')
|
||||
await registryMenu.waitFor({ state: 'hidden' })
|
||||
await dialog.getByRole('button', { name: '安装', exact: true }).click()
|
||||
await customAddress.waitFor({ state: 'visible' })
|
||||
await expect.poll(() => customAddress.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
expect(await customAddress.getAttribute('aria-invalid')).toBe('true')
|
||||
const registryStyles = await invalidInputStyles(page, customAddress)
|
||||
const invalidRegistryAria = await captureStableAria(page, '[data-install-registry] > [data-checked="true"]', scaffold.workspaceCwd)
|
||||
await registryMenu.getByRole('radio').first().check()
|
||||
await page.keyboard.press('Escape')
|
||||
await registryMenu.waitFor({ state: 'hidden' })
|
||||
const packageName = dialog.getByRole('textbox', { name: '包名或地址', exact: true })
|
||||
await packageName.fill('./relative')
|
||||
await packageName.press('Enter')
|
||||
await expect.poll(() => packageName.getAttribute('aria-invalid')).toBe('true')
|
||||
const packageStyles = await invalidInputStyles(page, packageName)
|
||||
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/invalid-registry.expected.md', import.meta.url)),
|
||||
`${invalidRegistryAria}\n\n${JSON.stringify({ registry: registryStyles, packageName: packageStyles }, null, 2)}`, webSnapshotMode())
|
||||
await packageName.fill('slow-package')
|
||||
// Hold request delivery and the real Host's cancellation reply independently.
|
||||
// Cancellation reaches the Host before the install it names.
|
||||
const cancellationArrived = Promise.withResolvers<undefined>()
|
||||
@@ -135,7 +207,54 @@ it('cancels installation through the UI, restores files, and offers the spec aga
|
||||
await dialog.getByRole('button', { name: '查看安装详情', exact: true }).click()
|
||||
await dialog.getByText('Retry completed', { exact: true }).waitFor()
|
||||
expect(JSON.parse(await readFile(manifestPath, 'utf8'))).toMatchObject({ dependencies: { 'slow-package': '1.0.0' } })
|
||||
await dialog.getByRole('button', { name: '关闭', exact: true }).click()
|
||||
// Freeze the real highlight's expiry timer and CSS first frame independently.
|
||||
await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1000))
|
||||
const pausedHighlight = await page.addStyleTag({ content: '[data-plugin-highlight] { animation-play-state: paused !important; }' })
|
||||
try {
|
||||
await dialog.getByRole('button', { name: '立即启用', exact: true }).click()
|
||||
await dialog.waitFor({ state: 'hidden' })
|
||||
const card = panel.locator('[data-plugin-package="slow-package"][data-plugin-highlight]')
|
||||
await card.waitFor({ state: 'visible' })
|
||||
// `enableInstalled` marks the card and starts the directory reload the
|
||||
// Host answers, so the highlight is observable before the reload
|
||||
// commits the enabled bundle into the list. Measured 61 ms between the
|
||||
// two locally; this poll waits for that commit, which the single read
|
||||
// it replaces sampled inside.
|
||||
await expect.poll(
|
||||
() => card.getAttribute('data-plugin-status'),
|
||||
{ timeout: 10_000 },
|
||||
).toBe('running')
|
||||
expect(await panel.locator('[data-plugin-highlight]').count()).toBe(1)
|
||||
const styles = []
|
||||
for (const colorScheme of ['light', 'dark'] as const) {
|
||||
await page.emulateMedia({ colorScheme })
|
||||
await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe(colorScheme === 'dark' ? '' : null)
|
||||
for (const reducedMotion of ['no-preference', 'reduce'] as const) {
|
||||
await page.emulateMedia({ reducedMotion })
|
||||
const style = await card.evaluate((element) => {
|
||||
const computed = getComputedStyle(element)
|
||||
return { boxShadow: computed.boxShadow, animationName: computed.animationName, animationDuration: computed.animationDuration }
|
||||
})
|
||||
expect(style.boxShadow).toMatch(/(?:\/|,)\s*0\.4\)/)
|
||||
expect(style.boxShadow).toContain('0px 0px 0px 2px')
|
||||
if (reducedMotion === 'reduce') {
|
||||
expect(style.animationName).toBe('none')
|
||||
} else {
|
||||
expect(style.animationName).toContain('dsh-plugin-highlight')
|
||||
expect(style.animationDuration).toBe('2.4s')
|
||||
}
|
||||
styles.push({ colorScheme, reducedMotion, boxShadow: style.boxShadow, animated: style.animationName !== 'none' })
|
||||
}
|
||||
}
|
||||
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/highlight.expected.md', import.meta.url)),
|
||||
`${await captureStableAria(page, '[data-plugin-package="slow-package"]', scaffold.workspaceCwd)}\n\n${JSON.stringify(styles, null, 2)}`, webSnapshotMode())
|
||||
await page.clock.runFor(2400)
|
||||
await card.waitFor({ state: 'detached' })
|
||||
} finally {
|
||||
await pausedHighlight.evaluate((element) => { element.parentNode?.removeChild(element) })
|
||||
await page.emulateMedia({ colorScheme: null, reducedMotion: null })
|
||||
await page.clock.resume()
|
||||
}
|
||||
await panel.getByRole('button', { name: '添加插件', exact: true }).click()
|
||||
await dialog.getByRole('textbox').fill('recovered-package')
|
||||
await page.route('**/api/pluginManager/installBundle', async (route) => {
|
||||
|
||||
@@ -75,7 +75,7 @@ it.each(['https://registry.npmjs.org/', MIRROR])('selects the fastest responding
|
||||
expect(await page.getByRole('radio').count()).toBe(0)
|
||||
await registryToggle.click()
|
||||
const options = page.getByRole('group', { name: '从哪个 npm 源下载插件', exact: true })
|
||||
const mirror = options.getByRole('radio', { name: '中国大陆镜像源(registry.npmmirror.com)', exact: true })
|
||||
const mirror = options.getByRole('radio', { name: '中国大陆镜像源 registry.npmmirror.com', exact: true })
|
||||
await expect.poll(async () => (await readFile(requestLog, 'utf8')).trim().split('\n').filter(Boolean)).toHaveLength(2)
|
||||
const pingUrls = (await readFile(requestLog, 'utf8')).trim().split('\n').sort()
|
||||
expect(pingUrls).toEqual(['https://registry.npmjs.org/-/ping', 'https://registry.npmmirror.com/-/ping'].map(url => JSON.stringify(url)))
|
||||
@@ -84,7 +84,7 @@ it.each(['https://registry.npmjs.org/', MIRROR])('selects the fastest responding
|
||||
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-registry/fastest-default.expected.md', import.meta.url)),
|
||||
await captureStableAria(page, '[data-install-registry]', scaffold.workspaceCwd), webSnapshotMode())
|
||||
} else {
|
||||
expect(await options.getByRole('radio', { name: 'npm 官方源(registry.npmjs.org)', exact: true }).isChecked()).toBe(true)
|
||||
expect(await options.getByRole('radio', { name: 'npm 官方源 registry.npmjs.org', exact: true }).isChecked()).toBe(true)
|
||||
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-registry/official-default.expected.md', import.meta.url)),
|
||||
await captureStableAria(page, '[data-install-registry]', scaffold.workspaceCwd), webSnapshotMode())
|
||||
await mirror.click()
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
// managed scaffold profile: installed bundles, their rows, and bundle enablement. Zero
|
||||
// model calls: everything is client state, seeded Session state, profile files, and the settings
|
||||
// document, so there is no fixture and a stray stream would fail loud on the open llm seam.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { mkdtemp, readFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import type { Browser, Locator, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { FiberState } from '@deepseek-ai/cordis'
|
||||
import { OPTIONAL_BUNDLES } from '@deepseek-ai/dsh-app-boot'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
SCAFFOLD_DEFAULTS_BUNDLE, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
@@ -93,6 +94,256 @@ describe('web e2e: plugin manager', () => {
|
||||
return readFile(join(scaffold.harnessHome, ...segments), 'utf8').catch(() => '')
|
||||
}
|
||||
|
||||
it('aligns the first-read skeleton with the loaded plugin cards', async () => {
|
||||
const facts: string[] = []
|
||||
let aria = ''
|
||||
const shots = MODE === 'refresh' ? await mkdtemp(join(tmpdir(), 'dsh-plugin-loading-')) : undefined
|
||||
for (const width of [1680, 1000]) {
|
||||
const context = await browser.newContext({ viewport: { width, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const listBundles = scaffold.ctx.pluginManager.listBundles.bind(scaffold.ctx.pluginManager)
|
||||
const reads: ReturnType<typeof listBundles>[] = []
|
||||
const spy = vi.spyOn(scaffold.ctx.pluginManager, 'listBundles').mockImplementation(() => {
|
||||
const read = release.promise.then(() => listBundles())
|
||||
reads.push(read)
|
||||
return read
|
||||
})
|
||||
try {
|
||||
const probe = await context.newPage()
|
||||
const consoleWatch = watchConsole(probe)
|
||||
await probe.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await probe.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await probe.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
|
||||
await expect.poll(() => spy.mock.calls.length, { timeout: 10_000 }).toBeGreaterThan(0)
|
||||
const panel = probe.locator('[data-plugin-panel]')
|
||||
const skeleton = panel.locator('[data-plugin-loading]')
|
||||
await skeleton.waitFor()
|
||||
await probe.evaluate(() => document.fonts.ready)
|
||||
expect(await skeleton.locator(':scope > ul > li').count()).toBe(4)
|
||||
expect(await skeleton.getAttribute('role')).toBe('status')
|
||||
expect(await skeleton.getAttribute('aria-label')).toBe('正在读取插件…')
|
||||
expect(await panel.getAttribute('aria-busy')).toBe('true')
|
||||
const actions = panel.locator(':scope > header button')
|
||||
expect(await actions.count()).toBe(2)
|
||||
for (const action of await actions.all()) expect(await action.isDisabled()).toBe(true)
|
||||
const loadingAria = await captureStableAria(probe, '[data-plugin-panel]', scaffold.workspaceCwd)
|
||||
if (aria === '') aria = loadingAria
|
||||
else expect(loadingAria).toBe(aria)
|
||||
|
||||
const measure = (group: Locator) => group.evaluate((element) => {
|
||||
const rect = (node: Element | null | undefined) => {
|
||||
if (node === null || node === undefined) throw new Error('Missing plugin layout element')
|
||||
const { x, y, width, height } = node.getBoundingClientRect()
|
||||
return { x, y, width, height }
|
||||
}
|
||||
return {
|
||||
pageHeader: rect(element.closest('[data-plugin-panel]')?.querySelector(':scope > header')),
|
||||
groupHeader: rect(element.firstElementChild),
|
||||
rows: Array.from(element.querySelectorAll(':scope > ul > li')).slice(0, 4).map((row) => {
|
||||
const head = row.firstElementChild
|
||||
const main = head?.children[1]
|
||||
return {
|
||||
row: rect(row), head: rect(head), icon: rect(head?.children[0]), main: rect(main),
|
||||
titleRow: rect(main?.children[0]), title: rect(main?.children[0]?.firstElementChild),
|
||||
description: rect(main?.children[1]), actions: rect(head?.children[2]),
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
const loading = await measure(skeleton)
|
||||
const blankActions = await skeleton.locator(':scope > ul > li > div > div:last-child').evaluateAll(nodes => nodes.map(node => ({
|
||||
children: node.childElementCount,
|
||||
background: getComputedStyle(node).backgroundColor,
|
||||
width: node.getBoundingClientRect().width,
|
||||
height: node.getBoundingClientRect().height,
|
||||
})))
|
||||
expect(blankActions).toEqual(Array.from({ length: 4 }, () => ({ children: 0, background: 'rgba(0, 0, 0, 0)', width: 36, height: 20 })))
|
||||
if (shots !== undefined) {
|
||||
for (const colorScheme of ['light', 'dark'] as const) {
|
||||
await probe.emulateMedia({ colorScheme })
|
||||
const path = join(shots, `loading-${width}-${colorScheme}.png`)
|
||||
await panel.screenshot({ path, animations: 'disabled' })
|
||||
console.log(`Plugin loading screenshot: ${path}`)
|
||||
}
|
||||
}
|
||||
const motion = await skeleton.evaluate(element => element.getAnimations({ subtree: true }).map(animation => ({
|
||||
duration: animation.effect?.getTiming().duration,
|
||||
opacityOnly: animation.effect instanceof KeyframeEffect
|
||||
&& animation.effect.getKeyframes().every(frame => frame.opacity !== undefined && frame.transform === undefined),
|
||||
})))
|
||||
expect(motion.length).toBe(13)
|
||||
expect(motion.every(animation => animation.duration === 2000 && animation.opacityOnly)).toBe(true)
|
||||
await probe.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await expect.poll(() => skeleton.evaluate(element => element.getAnimations({ subtree: true }).length)).toBe(0)
|
||||
await probe.emulateMedia({ reducedMotion: 'no-preference', colorScheme: null })
|
||||
|
||||
release.resolve(undefined)
|
||||
await skeleton.waitFor({ state: 'detached' })
|
||||
expect(await panel.getAttribute('aria-busy')).toBe('false')
|
||||
for (const action of await actions.all()) expect(await action.isDisabled()).toBe(false)
|
||||
const official = panel.locator('[data-plugin-group="official"]')
|
||||
await official.locator('[data-plugin-package]').first().waitFor()
|
||||
const loaded = await measure(official)
|
||||
expect(loaded.rows).toHaveLength(4)
|
||||
const compare = (name: string, a: typeof loading.pageHeader, b: typeof loaded.pageHeader, axes: readonly (keyof typeof a)[] = ['x', 'y', 'width', 'height']) => {
|
||||
for (const axis of axes) {
|
||||
expect(Math.abs(a[axis] - b[axis]), `${width}px ${name}.${axis}: loading=${a[axis]}, loaded=${b[axis]}`).toBeLessThanOrEqual(0.1)
|
||||
}
|
||||
}
|
||||
compare('pageHeader', loading.pageHeader, loaded.pageHeader)
|
||||
compare('groupHeader', loading.groupHeader, loaded.groupHeader)
|
||||
for (const [index, row] of loading.rows.entries()) {
|
||||
const real = loaded.rows[index]!
|
||||
for (const part of ['row', 'head', 'icon', 'main', 'titleRow', 'actions'] as const) compare(`row ${index + 1} ${part}`, row[part], real[part])
|
||||
// Painted text bars are deliberately shorter than real copy; their line origins and heights align.
|
||||
for (const part of ['title', 'description'] as const) compare(`row ${index + 1} ${part}`, row[part], real[part], ['x', 'y', 'height'])
|
||||
}
|
||||
facts.push(`${width}px: 4 rows; page/group headers, rows, icons, text lines and blank action spaces align within 0.1px`)
|
||||
expect(consoleWatch.pageErrors).toEqual([])
|
||||
} finally {
|
||||
release.resolve(undefined)
|
||||
spy.mockRestore()
|
||||
try {
|
||||
await context.close()
|
||||
} finally {
|
||||
await Promise.allSettled(reads)
|
||||
}
|
||||
}
|
||||
}
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'loading.expected.md'), [
|
||||
aria, '', ...facts,
|
||||
'Loading: header actions disabled; action spaces are empty 36×20 areas, with no switch placeholders',
|
||||
'Loaded: skeleton removed; header actions enabled',
|
||||
'Motion: 13 opacity-only pulses, 2000ms; reduced motion stops all pulses',
|
||||
].join('\n'), MODE)
|
||||
})
|
||||
|
||||
it('delays refresh hints and keeps cards through manual refresh, failure, and retry', async () => {
|
||||
const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
const listBundles = scaffold.ctx.pluginManager.listBundles.bind(scaffold.ctx.pluginManager)
|
||||
const reads: ReturnType<typeof listBundles>[] = []
|
||||
try {
|
||||
const probe = await context.newPage()
|
||||
const consoleWatch = watchConsole(probe)
|
||||
onTestFailed(() => saveFailureShot(probe, 'web-e2e-plugin-manager-refresh'))
|
||||
await probe.clock.install()
|
||||
await probe.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await probe.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
|
||||
const panel = probe.locator('[data-plugin-panel]')
|
||||
const refresh = panel.getByRole('button', { name: '刷新', exact: true })
|
||||
const add = panel.getByRole('button', { name: '添加插件', exact: true })
|
||||
await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).waitFor()
|
||||
await expect.poll(() => refresh.isEnabled()).toBe(true)
|
||||
await probe.evaluate(() => document.fonts.ready)
|
||||
const cards = panel.locator('[data-plugin-package], [data-plugin-item]')
|
||||
const cardText = await cards.allTextContents()
|
||||
expect(cardText.length).toBeGreaterThan(0)
|
||||
const tooltip = probe.getByRole('tooltip').filter({ hasText: '刷新' })
|
||||
const trace: string[] = []
|
||||
const recordRefresh = async (phase: string) => {
|
||||
trace.push(phase, await refresh.ariaSnapshot(), JSON.stringify({
|
||||
busy: await panel.getAttribute('aria-busy'),
|
||||
spinners: await refresh.locator('[data-state="ongoing"]').count(),
|
||||
cards: await cards.count(),
|
||||
skeletons: await panel.locator('[data-plugin-loading]').count(),
|
||||
alerts: await panel.getByRole('alert').allTextContents(),
|
||||
retry: await panel.getByRole('button', { name: '重试', exact: true }).count(),
|
||||
toasts: await probe.locator('body > [role="alert"]').allTextContents(),
|
||||
}))
|
||||
}
|
||||
const bounds = await refresh.boundingBox()
|
||||
if (bounds === null) throw new Error('Refresh button has no visible bounds')
|
||||
await probe.clock.pauseAt(await probe.evaluate(() => Date.now() + 1000))
|
||||
try {
|
||||
await probe.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2)
|
||||
await probe.clock.runFor(499)
|
||||
expect(await tooltip.count()).toBe(0)
|
||||
trace.push(`Hover at 499ms: ${await tooltip.count()} tooltips`)
|
||||
await probe.clock.runFor(1)
|
||||
await tooltip.waitFor()
|
||||
trace.push('Hover at 500ms:', await tooltip.ariaSnapshot())
|
||||
await probe.mouse.move(0, 999)
|
||||
await tooltip.waitFor({ state: 'detached' })
|
||||
|
||||
await add.focus()
|
||||
await probe.keyboard.press('Shift+Tab')
|
||||
expect(await refresh.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await probe.clock.runFor(499)
|
||||
expect(await tooltip.count()).toBe(0)
|
||||
trace.push(`Focus at 499ms: ${await tooltip.count()} tooltips`)
|
||||
await probe.clock.runFor(1)
|
||||
await tooltip.waitFor()
|
||||
trace.push('Focus at 500ms:', await tooltip.ariaSnapshot())
|
||||
await probe.keyboard.press('Tab')
|
||||
await tooltip.waitFor({ state: 'detached' })
|
||||
} finally {
|
||||
await probe.clock.resume()
|
||||
}
|
||||
|
||||
await probe.clock.pauseAt(await probe.evaluate(() => Date.now() + 1000))
|
||||
const spy = vi.spyOn(scaffold.ctx.pluginManager, 'listBundles')
|
||||
try {
|
||||
{
|
||||
// One failure round exercises the full path: kept cards and spinner while pending,
|
||||
// then the failure toast with no inline error over the stale cards. Success and retry
|
||||
// outcomes are the store's deterministic concern, covered by manager-store.client.spec.ts.
|
||||
const outcome = 'failure' as const
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const called = spy.mock.calls.length
|
||||
spy.mockImplementationOnce(() => {
|
||||
const read = release.promise.then(() => { throw new Error('Fixture plugin refresh failed') })
|
||||
reads.push(read)
|
||||
return read
|
||||
})
|
||||
try {
|
||||
await refresh.click()
|
||||
await expect.poll(() => spy.mock.calls.length).toBeGreaterThan(called)
|
||||
expect(await refresh.isDisabled()).toBe(true)
|
||||
expect(await refresh.getAttribute('aria-busy')).toBe('true')
|
||||
expect(await panel.getAttribute('aria-busy')).toBe('true')
|
||||
const spinner = refresh.locator('[data-state="ongoing"]')
|
||||
await spinner.waitFor()
|
||||
expect(await spinner.evaluate(element => element.getAnimations({ subtree: true }).length)).toBeGreaterThan(0)
|
||||
expect(await cards.allTextContents()).toEqual(cardText)
|
||||
expect(await panel.locator('[data-plugin-loading]').count()).toBe(0)
|
||||
expect(await tooltip.count()).toBe(0)
|
||||
expect(await panel.getByRole('alert').count()).toBe(0)
|
||||
expect(await probe.locator('body > [role="alert"]').count()).toBe(0)
|
||||
await recordRefresh(`${outcome}: pending`)
|
||||
|
||||
release.resolve(undefined)
|
||||
await probe.clock.runFor(400)
|
||||
await expect.poll(() => refresh.isEnabled()).toBe(true)
|
||||
expect(await refresh.getAttribute('aria-busy')).toBe('false')
|
||||
expect(await panel.getAttribute('aria-busy')).toBe('false')
|
||||
expect(await spinner.count()).toBe(0)
|
||||
expect(await cards.allTextContents()).toEqual(cardText)
|
||||
expect(await panel.locator('[data-plugin-loading]').count()).toBe(0)
|
||||
expect(await panel.getByRole('alert').count()).toBe(0)
|
||||
expect(await panel.getByRole('button', { name: '重试', exact: true }).count()).toBe(0)
|
||||
const toasts = probe.locator('body > [role="alert"]')
|
||||
await expect.poll(() => toasts.allTextContents()).toEqual(['刷新失败,请重试'])
|
||||
await recordRefresh(`${outcome}: settled`)
|
||||
} finally {
|
||||
release.resolve(undefined)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
await probe.clock.resume()
|
||||
}
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'refresh.expected.md'), trace.join('\n'), MODE)
|
||||
expect(consoleWatch.pageErrors).toEqual([])
|
||||
expect(consoleWatch.warnings).toEqual([])
|
||||
} finally {
|
||||
try {
|
||||
await context.close()
|
||||
} finally {
|
||||
await Promise.allSettled(reads)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('starts with an unavailable selected bundle and lets the user clear its error', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-missing-bundle'))
|
||||
const panel = await openPluginsPanel()
|
||||
@@ -123,6 +374,26 @@ describe('web e2e: plugin manager', () => {
|
||||
await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).waitFor({ timeout: 20_000 })
|
||||
const toggle = panel.getByRole('switch', { name: '启用 @fixture/bundle' })
|
||||
expect(await toggle.getAttribute('aria-checked')).toBe('false')
|
||||
const card = panel.locator('[data-plugin-package="@fixture/bundle"]')
|
||||
const open = card.getByRole('button', { name: '查看 @fixture/bundle', exact: true })
|
||||
await open.hover()
|
||||
const hoverRadius = await card.evaluate(element => getComputedStyle(element).borderRadius)
|
||||
await toggle.focus()
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
expect(await open.evaluate(element => element.matches(':focus-visible'))).toBe(true)
|
||||
const focusRing = await open.evaluate((element) => {
|
||||
const style = getComputedStyle(element, '::after')
|
||||
return {
|
||||
radius: style.borderRadius,
|
||||
cardRadius: style.getPropertyValue('--dsw-radius-xl').trim(),
|
||||
outlineStyle: style.outlineStyle,
|
||||
outlineWidth: Number.parseFloat(style.outlineWidth),
|
||||
}
|
||||
})
|
||||
expect(focusRing.radius).toBe(hoverRadius)
|
||||
expect(focusRing.radius).toBe(focusRing.cardRadius)
|
||||
expect(focusRing.outlineStyle).toBe('solid')
|
||||
expect(focusRing.outlineWidth).toBeGreaterThan(0)
|
||||
// The profile's own group holds its fixture bundle and the scaffold's defaults bundle; the installation's
|
||||
// optional bundles open the Official group, followed by the official plugins that registered their
|
||||
// configuration, and its other bundles stay off the page.
|
||||
@@ -345,6 +616,141 @@ describe('web e2e: plugin manager', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps IME confirmation Enter in install fields and submits only a plain Enter', async () => {
|
||||
const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
const manager = scaffold.ctx.pluginManager
|
||||
const manifest = await homeFile('profiles', 'scaffold', 'package.json')
|
||||
const registrySpy = vi.spyOn(manager, 'registries').mockResolvedValue({
|
||||
registry: null, fallbackRegistries: [], resolved: 'https://registry.npmjs.org/',
|
||||
})
|
||||
const inspectSpy = vi.spyOn(manager, 'inspect').mockImplementation(async (spec, options) => ({
|
||||
status: 'accepted', kind: 'registry', name: spec, version: '1.0.0', bundle: true, registry: options?.registry ?? null,
|
||||
}))
|
||||
const installSpy = vi.spyOn(manager, 'installBundle').mockImplementation(async spec => ({
|
||||
changed: false, application: 'failed', stage: 'install', target: spec,
|
||||
error: { code: 'operation-error', diagnostic: 'IME fixture: no package was installed' },
|
||||
}))
|
||||
try {
|
||||
const probe = await context.newPage()
|
||||
const consoleWatch = watchConsole(probe)
|
||||
onTestFailed(() => saveFailureShot(probe, 'web-e2e-plugin-manager-ime-enter'))
|
||||
await probe.clock.install()
|
||||
await probe.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await probe.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
|
||||
const panel = probe.locator('[data-plugin-panel]')
|
||||
await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).waitFor({ timeout: 20_000 })
|
||||
const trace: string[] = ['Synthetic browser KeyboardEvents, not an OS input-method test.']
|
||||
for (const target of ['package', 'custom registry'] as const) {
|
||||
inspectSpy.mockClear()
|
||||
installSpy.mockClear()
|
||||
await panel.getByRole('button', { name: '添加插件', exact: true }).click()
|
||||
const dialog = probe.getByRole('dialog', { name: '添加插件', exact: true })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
const spec = target === 'package' ? 'ime-confirm-package' : 'ime-confirm-registry'
|
||||
const specField = dialog.getByRole('textbox', { name: '包名或地址', exact: true })
|
||||
await specField.fill(spec)
|
||||
let field = specField
|
||||
const customRegistry = 'https://registry.example.test/'
|
||||
if (target === 'custom registry') {
|
||||
const registryToggle = dialog.getByRole('button', { name: /^安装源/ })
|
||||
await registryToggle.click()
|
||||
const registry = probe.locator('[data-install-registry]')
|
||||
const offered = registry.getByRole('radio', { name: 'npm 官方源 registry.npmjs.org', exact: true })
|
||||
const custom = registry.getByRole('radio', { name: '自定义地址', exact: true })
|
||||
field = registry.getByRole('textbox', { name: '自定义地址', exact: true })
|
||||
expect(await offered.isChecked()).toBe(true)
|
||||
expect(await custom.isChecked()).toBe(false)
|
||||
await offered.focus()
|
||||
await probe.keyboard.press('Tab')
|
||||
expect(await field.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
expect(await offered.isChecked()).toBe(true)
|
||||
expect(await custom.isChecked()).toBe(false)
|
||||
await probe.keyboard.press('Shift+Tab')
|
||||
expect(await offered.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await probe.keyboard.press('Shift+Tab')
|
||||
await registry.waitFor({ state: 'detached' })
|
||||
expect(await registryToggle.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await probe.keyboard.press('Enter')
|
||||
await custom.focus()
|
||||
await probe.keyboard.press('Space')
|
||||
expect(await custom.isChecked()).toBe(true)
|
||||
expect(await offered.isChecked()).toBe(false)
|
||||
expect(await field.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await field.fill(customRegistry)
|
||||
await probe.keyboard.press('Shift+Tab')
|
||||
expect(await custom.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await probe.keyboard.press('Tab')
|
||||
expect(await field.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await probe.keyboard.press('Tab')
|
||||
await registry.waitFor({ state: 'detached' })
|
||||
expect(await registryToggle.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await probe.keyboard.press('Enter')
|
||||
await custom.focus()
|
||||
await probe.keyboard.press('Tab')
|
||||
expect(await field.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
expect(await custom.isChecked()).toBe(true)
|
||||
}
|
||||
const value = target === 'package' ? spec : customRegistry
|
||||
const assertUnsubmitted = async (label: string) => {
|
||||
expect(await field.inputValue()).toBe(value)
|
||||
expect(await field.isEditable()).toBe(true)
|
||||
expect(await dialog.getByRole('button', { name: '安装', exact: true }).isEnabled()).toBe(true)
|
||||
expect(inspectSpy).not.toHaveBeenCalled()
|
||||
expect(installSpy).not.toHaveBeenCalled()
|
||||
trace.push(`${target} / ${label}: inspect=0, install=0; editable; value retained`, await field.ariaSnapshot())
|
||||
}
|
||||
await probe.clock.pauseAt(await probe.evaluate(() => Date.now() + 1000))
|
||||
try {
|
||||
for (const event of [
|
||||
{ label: 'isComposing=true', isComposing: true, keyCode: 13 },
|
||||
{ label: 'Safari isComposing=false, keyCode=229', isComposing: false, keyCode: 229 },
|
||||
]) {
|
||||
await field.dispatchEvent('keydown', {
|
||||
key: 'Enter', code: 'Enter', bubbles: true, cancelable: true,
|
||||
isComposing: event.isComposing, keyCode: event.keyCode,
|
||||
})
|
||||
await assertUnsubmitted(event.label)
|
||||
}
|
||||
const unmarkedEnter = { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true, isComposing: false, keyCode: 13 }
|
||||
await field.dispatchEvent('compositionstart')
|
||||
await field.dispatchEvent('keydown', unmarkedEnter)
|
||||
await assertUnsubmitted('compositionstart + unmarked Enter')
|
||||
await field.dispatchEvent('compositionend')
|
||||
await field.dispatchEvent('keydown', unmarkedEnter)
|
||||
await assertUnsubmitted('compositionend + immediate unmarked Enter')
|
||||
await probe.clock.runFor(9)
|
||||
await field.dispatchEvent('keydown', unmarkedEnter)
|
||||
await assertUnsubmitted('compositionend + 9ms unmarked Enter')
|
||||
await probe.clock.runFor(2)
|
||||
} finally {
|
||||
await probe.clock.resume()
|
||||
}
|
||||
await field.press('Enter')
|
||||
await expect.poll(() => inspectSpy.mock.calls.length, { timeout: 10_000 }).toBe(1)
|
||||
await expect.poll(() => installSpy.mock.calls.length, { timeout: 10_000 }).toBe(1)
|
||||
expect(inspectSpy.mock.calls[0]?.[0]).toBe(spec)
|
||||
expect(installSpy.mock.calls[0]?.[0]).toBe(spec)
|
||||
expect(installSpy.mock.calls[0]?.[1]).toMatchObject({ enabled: false, registry: target === 'package' ? null : customRegistry })
|
||||
const failed = probe.getByRole('dialog', { name: '插件安装失败', exact: true })
|
||||
await failed.waitFor({ timeout: 10_000 })
|
||||
trace.push(`${target} / plain Enter: inspect=1, install=1`, await failed.ariaSnapshot())
|
||||
await failed.getByRole('button', { name: '关闭', exact: true }).click()
|
||||
await failed.waitFor({ state: 'hidden', timeout: 10_000 })
|
||||
}
|
||||
expect(await homeFile('profiles', 'scaffold', 'package.json')).toBe(manifest)
|
||||
expect(consoleWatch.pageErrors).toEqual([])
|
||||
trace.push('Profile manifest unchanged; controlled Host result performed no package installation.')
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'ime-enter.expected.md'), trace.join('\n'), MODE)
|
||||
} finally {
|
||||
try { await context.close() }
|
||||
finally {
|
||||
registrySpy.mockRestore()
|
||||
inspectSpy.mockRestore()
|
||||
installSpy.mockRestore()
|
||||
}
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it('checks a spec before installing it and words what the check refused', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-install'))
|
||||
const panel = await openPluginsPanel()
|
||||
@@ -416,7 +822,8 @@ describe('web e2e: plugin manager', () => {
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', 'icons.expected.md',
|
||||
'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', 'icons.expected.md', 'loading.expected.md', 'refresh.expected.md',
|
||||
'ime-enter.expected.md',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -65,6 +65,39 @@ it('shows completed paragraph first lines across blank lines with a right-edge f
|
||||
const reasoning = page.locator('[data-variant="think"][data-state="running"]')
|
||||
await expandOwningTurnProcess(page, reasoning)
|
||||
await reasoning.waitFor()
|
||||
const whale = page.locator('[data-chat-running] svg')
|
||||
const animatedWhale = whale.locator('path:has(animate)')
|
||||
const restingWhale = whale.locator('path:not(:has(animate))')
|
||||
await animatedWhale.locator('animate').waitFor({ state: 'attached' })
|
||||
const positions = await animatedWhale.evaluate(async (element) => {
|
||||
const path = element as SVGPathElement
|
||||
const svg = path.ownerSVGElement!
|
||||
const time = svg.getCurrentTime()
|
||||
svg.pauseAnimations()
|
||||
try {
|
||||
const positions: number[][] = []
|
||||
for (const at of [0.3, 0.9, 3.3]) {
|
||||
svg.setCurrentTime(at)
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => { resolve() }))
|
||||
const point = path.getPointAtLength(path.getTotalLength() * 0.4)
|
||||
positions.push([point.x, point.y])
|
||||
}
|
||||
return positions
|
||||
} finally {
|
||||
svg.setCurrentTime(time)
|
||||
svg.unpauseAnimations()
|
||||
}
|
||||
})
|
||||
expect(positions[1]).not.toEqual(positions[0])
|
||||
expect(positions[2]).toEqual(positions[0])
|
||||
expect(await animatedWhale.evaluate(element => getComputedStyle(element).display)).not.toBe('none')
|
||||
expect(await restingWhale.evaluate(element => getComputedStyle(element).display)).toBe('none')
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await expect.poll(() => animatedWhale.evaluate(element => getComputedStyle(element).display)).toBe('none')
|
||||
await expect.poll(() => restingWhale.evaluate(element => getComputedStyle(element).display)).not.toBe('none')
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' })
|
||||
await expect.poll(() => animatedWhale.evaluate(element => getComputedStyle(element).display)).not.toBe('none')
|
||||
await expect.poll(() => restingWhale.evaluate(element => getComputedStyle(element).display)).toBe('none')
|
||||
expect(await reasoning.getAttribute('data-preview')).toBeNull()
|
||||
|
||||
first.proceed.resolve(undefined)
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, pinBrowserClock, saveFailureShot, WEB_FIXTURE_TIME, writeComposerDraft } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/reference-composer', import.meta.url))
|
||||
const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md')
|
||||
@@ -142,14 +142,16 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let unpinBrowserClock: (() => void) | undefined
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
const targetCreatedAt = Date.now() - 60_000
|
||||
const targetCreatedAt = WEB_FIXTURE_TIME - 60_000
|
||||
await seedSession(scaffold, sourceSessionFixture(), SOURCE_SESSION_ID, undefined, { createdAt: targetCreatedAt - 1 })
|
||||
await seedSession(scaffold, targetSessionFixture(), TARGET_SESSION_ID, undefined, { createdAt: targetCreatedAt })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
unpinBrowserClock = await pinBrowserClock(page)
|
||||
tripwire = watchConsole(page)
|
||||
// Fixture files land before the workspace connects so the Host's file
|
||||
// index never races their creation (the connect helper mkdirs the same
|
||||
@@ -169,6 +171,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
unpinBrowserClock?.()
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
@@ -462,9 +462,12 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
|
||||
await row.waitFor({ timeout: 15_000 })
|
||||
expect(await row.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
|
||||
expect(await row.textContent()).toContain(AFTER_REPLY)
|
||||
const triggerTitle = '[data-turn-trigger] > button > span:nth-child(2)'
|
||||
await page.locator(triggerTitle).waitFor()
|
||||
expect(await page.locator(triggerTitle).textContent()).toBe('Automation task')
|
||||
await compareOrRefreshGolden(
|
||||
AFTER_EXPECTED,
|
||||
await captureStableAria(page, selector, scaffold.workspaceCwd),
|
||||
`${await captureStableAria(page, triggerTitle, scaffold.workspaceCwd)}\n\n${await captureStableAria(page, selector, scaffold.workspaceCwd)}`,
|
||||
MODE,
|
||||
)
|
||||
await expectNoReminderEntry(page)
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
- tab "Trajectory"
|
||||
- text: Stream one TypeScript fence for the highlighting snapshot. {{clock}}
|
||||
- button "Copy"
|
||||
- status: Deep diving...
|
||||
- button "Deep diving for {{duration}}" [disabled] [expanded]
|
||||
- text: ts
|
||||
- button "Wrap lines" [pressed]
|
||||
- button "Copy"
|
||||
- code: "const first: number = 1 const second = \"two\" let tail"
|
||||
- status: Deep diving...
|
||||
- text: Deep diving for {{duration}}...
|
||||
- textbox "Message or run a task, / commands, @ files or sessions"
|
||||
- button "Add files or run commands"
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
|
||||
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page, WebSocketRoute } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed, type MockInstance } from 'vitest'
|
||||
import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { expandAssistantStream } from '@deepseek-ai/dsh-llm'
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettings, connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { openSettings, connectFreshWorkspace, newEnglishPage, pinBrowserClock, pinHostClock, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/steering', import.meta.url))
|
||||
const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
|
||||
@@ -72,15 +72,22 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let hostClock: MockInstance<typeof Date.now> | undefined
|
||||
let unpinBrowserClock: (() => void) | undefined
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
// This scenario types its own messages, so the Host stamps their times: both
|
||||
// clocks must read the fixture day or a run across Asia/Shanghai midnight
|
||||
// renders the same message with a date prefix.
|
||||
hostClock = pinHostClock()
|
||||
scaffold = await launchWebScaffold(MODE === 'record'
|
||||
? {}
|
||||
: { replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS, compareReplaySession: true })
|
||||
scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
unpinBrowserClock = await pinBrowserClock(page)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
@@ -89,8 +96,15 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
try {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
} finally {
|
||||
// A failing close (unconsumed fixture, mismatched snapshot) must not
|
||||
// leave the pinned clocks running for the suites after this one.
|
||||
unpinBrowserClock?.()
|
||||
hostClock?.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('strictly steers one queued row; the interjection is logged, rendered, and obeyed', async () => {
|
||||
@@ -381,9 +395,12 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let hostClock: MockInstance<typeof Date.now> | undefined
|
||||
let unpinBrowserClock: (() => void) | undefined
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
hostClock = pinHostClock()
|
||||
// The scenario boots a fresh session against the override-only fixture;
|
||||
// the replay.override.json sidecar replaces the derived script, so the
|
||||
// (deliberately absent) session.jsonl is never read.
|
||||
@@ -399,6 +416,7 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
|
||||
scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
unpinBrowserClock = await pinBrowserClock(page)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
@@ -409,8 +427,13 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
|
||||
afterAll(async () => {
|
||||
releaseReplay.resolve(undefined)
|
||||
disposeReplayBarrier?.()
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
try {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
} finally {
|
||||
unpinBrowserClock?.()
|
||||
hostClock?.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('queues two messages, then flushes both with an empty-draft Cmd+Enter', async () => {
|
||||
@@ -424,6 +447,10 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
|
||||
await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 })
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
// The turn must report busy before the next two sends: Enter's queue gesture
|
||||
// resolved against a not-yet-running agent admits the message into the turn
|
||||
// instead of publishing a Queue row.
|
||||
await page.getByRole('button', { name: 'Stop generating' }).waitFor({ timeout: 10_000 })
|
||||
await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 })
|
||||
await input.fill(STEER_ONE)
|
||||
await input.press('Enter')
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createRequire } from 'node:module'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Locator, Page } from 'playwright'
|
||||
import { expect } from 'vitest'
|
||||
import { expect, vi, type MockInstance } from 'vitest'
|
||||
|
||||
/** The built page under test; `pnpm run test:web` rebuilds it before running. */
|
||||
export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.meta.url))
|
||||
@@ -41,6 +41,57 @@ export const ZH_BROWSER_LOCALE = 'zh-CN'
|
||||
/** Same-day anchor for seeded event times and the Asia/Shanghai browser clock. */
|
||||
export const WEB_FIXTURE_TIME = Date.parse('2026-01-15T12:00:00+08:00')
|
||||
|
||||
/** How often a pinned browser clock advances, in milliseconds. */
|
||||
const PINNED_CLOCK_STEP_MS = 250
|
||||
|
||||
/**
|
||||
* Pin the Host clock to the shared fixture day for one scenario suite.
|
||||
*
|
||||
* A rendered message clock gains a `clock.md` / `clock.ymd` date prefix as soon
|
||||
* as the message's local day differs from the renderer's, so a scenario whose
|
||||
* message times are stamped by the Host wall clock — a live composer send, or a
|
||||
* seed anchored from `Date.now()` — renders a different aria line once the run
|
||||
* spans `Asia/Shanghai` midnight. Reading the fixture day on both clocks makes
|
||||
* that calendar fact part of the scenario rather than of the run.
|
||||
*
|
||||
* The pinned clock advances with real elapsed time, so turn deadlines, session
|
||||
* ordering, and durations keep running.
|
||||
* @returns the installed `Date.now` spy, restored by the caller in teardown.
|
||||
*/
|
||||
export function pinHostClock(): MockInstance<typeof Date.now> {
|
||||
const startedAt = performance.now()
|
||||
return vi.spyOn(Date, 'now').mockImplementation(() =>
|
||||
WEB_FIXTURE_TIME + Math.floor(performance.now() - startedAt))
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin a scenario page's clock to the shared fixture day, advancing with real
|
||||
* time. Pair it with {@link pinHostClock} for a scenario that stamps its own
|
||||
* message times, and with `seedSession`'s `createdAt` for a seeded one.
|
||||
*
|
||||
* The frozen instant is re-applied from the anchor on an interval rather than
|
||||
* left at one value, because product logic reads two `Date.now()` values to
|
||||
* decide a minimum display hold or a deadline window; a permanently frozen
|
||||
* clock stops supplying elapsed time, so a title that owes its golden settled
|
||||
* text keeps rendering its previous one. Both clocks therefore advance at the
|
||||
* real rate and stay within one step of each other.
|
||||
* @param page - page whose clock is pinned.
|
||||
* @returns the disposer that stops re-pinning; call it in teardown.
|
||||
*/
|
||||
export async function pinBrowserClock(page: Page): Promise<() => void> {
|
||||
const startedAt = Date.now()
|
||||
const step = async (): Promise<void> => {
|
||||
try {
|
||||
await page.clock.setFixedTime(WEB_FIXTURE_TIME + (Date.now() - startedAt))
|
||||
} catch {
|
||||
// The page closed under the interval; teardown owns the last word.
|
||||
}
|
||||
}
|
||||
await step()
|
||||
const timer = setInterval(() => { void step() }, PINNED_CLOCK_STEP_MS)
|
||||
return () => { clearInterval(timer) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the standard browser-test page advertising English before client boot.
|
||||
* This keeps role locators and goldens deterministic while leaving the Host
|
||||
@@ -94,8 +145,7 @@ export async function expandOwningTurnProcess(page: Page, target: Locator): Prom
|
||||
const turn = await target.evaluate(element => element.closest<HTMLElement>('[data-chat-turn]')?.dataset.chatTurn)
|
||||
if (turn !== undefined) {
|
||||
const control = page.locator(`[data-turn-process="${turn}"]`)
|
||||
await control.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
if (await control.getAttribute('aria-expanded') === 'false') await control.click()
|
||||
if (await control.count() > 0 && await control.getAttribute('aria-expanded') === 'false') await control.click()
|
||||
}
|
||||
const group = target.locator('xpath=ancestor::*[@data-chat-group-key][1]')
|
||||
const header = group.locator('[data-process-activity]').first()
|
||||
|
||||
@@ -11,7 +11,7 @@ import { existsSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import type { Browser, Locator, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterEach, describe, expect, it, onTestFailed, vi } from 'vitest'
|
||||
import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
|
||||
@@ -38,6 +38,22 @@ const MODE = webSnapshotMode()
|
||||
const NARRATION = 'Reading the workspace now.'
|
||||
const PROMPT = `Begin your reply with the plain sentence "${NARRATION}" as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop.`
|
||||
|
||||
/**
|
||||
* Focus the Copy IconAction and wait for the tooltip the running golden records.
|
||||
*
|
||||
* `Tooltip` ignores focus while the last input came from a pointer, and any
|
||||
* keydown clears that flag, so a focus that follows this scenario's own clicks
|
||||
* raises no bubble. Clear the flag with a key that moves nothing, then focus
|
||||
* and wait for the bubble rather than assuming its commit landed.
|
||||
* @param page - page containing the Copy button.
|
||||
* @param button - the Copy button to focus.
|
||||
*/
|
||||
async function expectFocusTooltip(page: Page, button: Locator): Promise<void> {
|
||||
await page.keyboard.press('Shift')
|
||||
await button.focus()
|
||||
await page.getByRole('tooltip', { name: 'Copy', exact: true }).waitFor({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
describe('web e2e: assistant IconActions wait for the turn to end', () => {
|
||||
let scaffold: WebScaffold | undefined
|
||||
let browser: Browser | undefined
|
||||
@@ -144,11 +160,9 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
|
||||
// so the first step's message and tool result are already durable.
|
||||
await expect.poll(() => existsSync(marker), { timeout: 20_000 }).toBe(true)
|
||||
const runningProcess = page.locator('[data-turn-process]')
|
||||
expect(await runningProcess.count()).toBe(1)
|
||||
expect(await runningProcess.isDisabled()).toBe(true)
|
||||
expect(await runningProcess.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(await runningProcess.count()).toBe(0)
|
||||
await expect.poll(
|
||||
() => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(),
|
||||
() => page.getByRole('status').filter({ hasText: 'Deep diving' }).isVisible(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(true)
|
||||
await page.locator('[data-streaming="true"]')
|
||||
@@ -159,7 +173,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
|
||||
const copyButtons = page.getByRole('button', { name: 'Copy' })
|
||||
await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await page.getByRole('button', { name: 'Branch into a new conversation' }).count()).toBe(0)
|
||||
await copyButtons.first().focus()
|
||||
await expectFocusTooltip(page, copyButtons.first())
|
||||
const running = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
|
||||
await compareOrRefreshGolden(RUNNING_EXPECTED, running, MODE)
|
||||
|
||||
@@ -172,6 +186,8 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
|
||||
await page.locator('[data-turn-process]').waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBe(2)
|
||||
await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0)
|
||||
// The settled golden records no bubble: this focus follows the Stop click,
|
||||
// which leaves the pointer owning the last input.
|
||||
await copyButtons.last().focus()
|
||||
const settledAria = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
|
||||
await compareOrRefreshGolden(SETTLED_EXPECTED, settledAria, MODE)
|
||||
|
||||
@@ -74,6 +74,12 @@ describe('web e2e: blank New Session folding quota', () => {
|
||||
expect(await sidebar.getByText('Untitled', { exact: true }).count()).toBe(5)
|
||||
const showMore = sidebar.getByRole('button', { name: 'Show 11 more sessions' })
|
||||
await showMore.waitFor({ timeout: 15_000 })
|
||||
// The sidebar golden records its workspace row hovered, which reveals the
|
||||
// row actions `.projectRow:hover` otherwise hides. Chromium does not
|
||||
// recompute `:hover` when a re-render moves rows under a pointer that does
|
||||
// not move, so the state is set rather than inherited from the New Session
|
||||
// click above.
|
||||
await sidebar.getByRole('treeitem').first().hover()
|
||||
await compareOrRefreshGolden(
|
||||
SIDEBAR_EXPECTED,
|
||||
await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd),
|
||||
@@ -83,6 +89,13 @@ describe('web e2e: blank New Session folding quota', () => {
|
||||
await showMore.click()
|
||||
await expect.poll(() => sidebar.getByRole('treeitem').count(), { timeout: 10_000 }).toBe(12)
|
||||
expect(await sidebar.getByText('Untitled', { exact: true }).count()).toBe(10)
|
||||
// `first-batch.expected.md` records the first row the batch reveals
|
||||
// hovered — the sixth session, which takes the former Show-more button's
|
||||
// position. Under `:hover` its trailing cell swaps the relative time for
|
||||
// the row actions. The `showMore.click()` above reflows the list beneath a
|
||||
// stationary pointer, so the row that ends up there follows the layout,
|
||||
// not the scenario; hover the row the golden records instead.
|
||||
await sidebar.getByRole('treeitem').nth(7).hover()
|
||||
await compareOrRefreshGolden(
|
||||
join(EXPECTED_DIR, 'first-batch.expected.md'),
|
||||
await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd),
|
||||
|
||||
@@ -59,7 +59,7 @@ async function measure(capacityBytes: number, mode: string): Promise<TerminalIoR
|
||||
rows: 40, cols: 160, scrollbackLines: 10_000, scrollbackMaxBytes: capacityBytes,
|
||||
maxReadBytes: Math.min(256 * 1024, capacityBytes),
|
||||
pollIntervalMs: 1, exactProbeAfterMs: 150, idleSilenceMs: 1,
|
||||
handoffGraceMs: 1, timeoutMs: 120_000, disposeGraceMs: 1,
|
||||
handoffGraceMs: 1, promptTailGraceMs: 0, timeoutMs: 120_000, disposeGraceMs: 1,
|
||||
})
|
||||
try {
|
||||
if (prefillBytes > 0) {
|
||||
|
||||
@@ -3384,6 +3384,15 @@ export interface Config {
|
||||
* regain the foreground before `inferred_idle` settles; at least one `pollIntervalMs`.
|
||||
*/
|
||||
handoffGraceMs?: number
|
||||
/**
|
||||
* Extra wait beyond `idleSilenceMs` and `handoffGraceMs`, once a prompt marker was seen but
|
||||
* its printable tail has not arrived, before `inferred_idle` settles. The marker is written
|
||||
* by the shell's own prompt function and the tail by the same render, so a missing tail is a
|
||||
* delivery delay on a contended host rather than an absent prompt. Zero keeps the bound at
|
||||
* `idleSilenceMs + handoffGraceMs`; any other value covers at least one `pollIntervalMs`, so a
|
||||
* nonzero tolerance always contains a readiness poll.
|
||||
*/
|
||||
promptTailGraceMs?: number
|
||||
/** Absolute bound for one send and the complete pwsh startup sequence. */
|
||||
timeoutMs?: number
|
||||
/** Grace before teardown escalates to `SIGKILL`. */
|
||||
|
||||
@@ -3386,6 +3386,15 @@ export interface Config {
|
||||
* regain the foreground before `inferred_idle` settles; at least one `pollIntervalMs`.
|
||||
*/
|
||||
handoffGraceMs?: number
|
||||
/**
|
||||
* Extra wait beyond `idleSilenceMs` and `handoffGraceMs`, once a prompt marker was seen but
|
||||
* its printable tail has not arrived, before `inferred_idle` settles. The marker is written
|
||||
* by the shell's own prompt function and the tail by the same render, so a missing tail is a
|
||||
* delivery delay on a contended host rather than an absent prompt. Zero keeps the bound at
|
||||
* `idleSilenceMs + handoffGraceMs`; any other value covers at least one `pollIntervalMs`, so a
|
||||
* nonzero tolerance always contains a readiness poll.
|
||||
*/
|
||||
promptTailGraceMs?: number
|
||||
/** Absolute bound for one send and the complete pwsh startup sequence. */
|
||||
timeoutMs?: number
|
||||
/** Grace before teardown escalates to `SIGKILL`. */
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Keep host Git command-line configuration out of the Git children these specs spawn.
|
||||
*
|
||||
* Git exports its indexed command-line configuration (`GIT_CONFIG_COUNT` with
|
||||
* `GIT_CONFIG_KEY_n` / `GIT_CONFIG_VALUE_n`, or the older `GIT_CONFIG_PARAMETERS`)
|
||||
* to `git -c` calls and to hook children. The shared subprocess scrub removes
|
||||
* credential-shaped names, so a child that inherits the counter without its key
|
||||
* fails before it reads any configuration file (`error: missing config key
|
||||
* GIT_CONFIG_KEY_0`). These specs judge profile behavior rather than host state,
|
||||
* so they run Git without the host's group.
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest'
|
||||
|
||||
/** Names Git uses for its indexed command-line configuration. */
|
||||
const COMMAND_LINE_CONFIG_NAME = /^GIT_CONFIG_(?:COUNT|PARAMETERS|KEY_\d+|VALUE_\d+)$/u
|
||||
|
||||
/**
|
||||
* Delete Git's command-line configuration group from this process's environment.
|
||||
* @returns a disposer restoring every environment entry the call removed.
|
||||
*/
|
||||
export function isolateGitCommandLineConfig(): () => void {
|
||||
for (const name of Object.keys(process.env)) {
|
||||
if (COMMAND_LINE_CONFIG_NAME.test(name)) vi.stubEnv(name, undefined)
|
||||
}
|
||||
// `unstubAllEnvs` is the documented inverse of `stubEnv`; neither spec stubs another name.
|
||||
return () => { vi.unstubAllEnvs() }
|
||||
}
|
||||
@@ -8,15 +8,21 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import * as subprocess from 'execa'
|
||||
import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { afterAll, beforeAll, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { checkGithubConnection } from '../src/github-connection.ts'
|
||||
import { parseInstallSpec } from '../src/install-spec.ts'
|
||||
import { isolateGitCommandLineConfig } from './git-environment.ts'
|
||||
|
||||
vi.mock('execa', async (original) => {
|
||||
const module = await original<typeof import('execa')>()
|
||||
return { ...module, execa: vi.fn(module.execa) }
|
||||
})
|
||||
|
||||
// These cases spawn real Git, so the host's command-line configuration group must not reach its children.
|
||||
let restoreGitCommandLineConfig: () => void
|
||||
beforeAll(() => { restoreGitCommandLineConfig = isolateGitCommandLineConfig() })
|
||||
afterAll(() => { restoreGitCommandLineConfig() })
|
||||
|
||||
async function fixture() {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-github-check-'))
|
||||
onTestFinished(() => rm(dir, { recursive: true, force: true }))
|
||||
|
||||
@@ -9,7 +9,7 @@ import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { afterAll, beforeAll, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import {
|
||||
boot, composeEntries, initProfile, loadProfileDirectory, readProfilePatches, readProfileManifest,
|
||||
reconcileProfilePatches, OPTIONAL_BUNDLES, PluginPackages, readPluginMeta, getDshRuntimeVersion,
|
||||
@@ -23,6 +23,12 @@ import { Group } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import * as operations from '../src/operations.ts'
|
||||
import * as githubConnection from '../src/github-connection.ts'
|
||||
import { parse, parseDocument } from 'yaml'
|
||||
import { isolateGitCommandLineConfig } from './git-environment.ts'
|
||||
|
||||
// These cases install through real Git and pnpm, so the host's command-line configuration group must not reach their children.
|
||||
let restoreGitCommandLineConfig: () => void
|
||||
beforeAll(() => { restoreGitCommandLineConfig = isolateGitCommandLineConfig() })
|
||||
afterAll(() => { restoreGitCommandLineConfig() })
|
||||
|
||||
async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, prepare?: (ctx: Context) => void, config: Config = {}, packageManager?: ProfileContext['packageManager'], prepareFiles?: (dir: string) => void) {
|
||||
const temporaryHome = mkdtempSync(join(tmpdir(), 'plugin-manager-'))
|
||||
|
||||
@@ -598,22 +598,50 @@ function runRecord(dir: string): string {
|
||||
return join(dir, '.plugin-manager', 'run.json')
|
||||
}
|
||||
|
||||
/** A run that exits after `ms`, reading the profile's run record while it is still running. */
|
||||
function observingChild(dir: string, pid: number, ms = 100) {
|
||||
/**
|
||||
* The record an operation writes for the run it started, read once the record
|
||||
* appears. The write follows the run's launch, so a bound this far above one
|
||||
* atomic file write keeps a missing record reporting as the assertion that
|
||||
* names it rather than as a runner timeout.
|
||||
*/
|
||||
const RECORD_WAIT_MS = 2_000
|
||||
|
||||
/**
|
||||
* A run that stays in flight until the test has read the profile's run record,
|
||||
* so the read observes the run and cannot race its end. `spawned` is the
|
||||
* mocked launcher's hand-over: the wait for the record starts when the run
|
||||
* does, not before the operation reached it under a loaded runner.
|
||||
*/
|
||||
function observingChild(dir: string, pid: number, waitMs = RECORD_WAIT_MS) {
|
||||
const stdout = new PassThrough()
|
||||
const stderr = new PassThrough()
|
||||
const raw = new EventEmitter()
|
||||
const observed: { record: string | undefined } = { record: undefined }
|
||||
const spawned = Promise.withResolvers<undefined>()
|
||||
const done = new Promise<FakeOutcome>((resolve) => {
|
||||
setTimeout(() => {
|
||||
observed.record = existsSync(runRecord(dir)) ? readFileSync(runRecord(dir), 'utf8') : undefined
|
||||
void spawned.promise.then(async () => {
|
||||
observed.record = await recordedRun(dir, waitMs)
|
||||
stdout.end()
|
||||
stderr.end()
|
||||
raw.emit('exit', 0, null)
|
||||
resolve({ exitCode: 0, failed: false, stdout: '', stderr: '' })
|
||||
}, ms)
|
||||
})
|
||||
})
|
||||
return { child: Object.assign(done, { stdout, stderr, nodeChildProcess: raw, pid }), observed }
|
||||
return {
|
||||
child: Object.assign(done, { stdout, stderr, nodeChildProcess: raw, pid }),
|
||||
observed,
|
||||
spawned: () => { spawned.resolve(undefined) },
|
||||
}
|
||||
}
|
||||
|
||||
/** The written run record, or undefined once `waitMs` passes without one. */
|
||||
async function recordedRun(dir: string, waitMs: number): Promise<string | undefined> {
|
||||
const deadline = Date.now() + waitMs
|
||||
for (;;) {
|
||||
if (existsSync(runRecord(dir))) return readFileSync(runRecord(dir), 'utf8')
|
||||
if (Date.now() >= deadline) return undefined
|
||||
await new Promise(resolve => setTimeout(resolve, 1))
|
||||
}
|
||||
}
|
||||
|
||||
it.each([8192, 30])('refuses to run while a run recorded by an exited operation is still active (output bound %i)', async (outputBytes) => {
|
||||
@@ -667,8 +695,8 @@ it.each(['not json', 'null', '{"pid":0,"grouped":false}', '{"pid":12,"grouped":"
|
||||
|
||||
it.each(['cli', 'service'] as const)('records a %s run while it runs and removes the record once it ends', async (execution) => {
|
||||
const { dir, context } = fixture()
|
||||
const { child, observed } = observingChild(dir, 4242)
|
||||
command.run.mockImplementationOnce(() => child)
|
||||
const { child, observed, spawned } = observingChild(dir, 4242)
|
||||
command.run.mockImplementationOnce(() => { spawned(); return child })
|
||||
const outcome = await runProfilePnpm(context, ['list'], { execution, outputBytes: 8192 })
|
||||
expect(outcome.exitCode).toBe(0)
|
||||
expect(JSON.parse(observed.record ?? 'null')).toEqual({
|
||||
@@ -683,7 +711,9 @@ it('records the install that repairs a refused installation', async () => {
|
||||
const base = command.run.getMockImplementation() as (...call: unknown[]) => Promise<FakeOutcome> & FakeChild
|
||||
const repair = observingChild(dir, 4343)
|
||||
command.run.mockImplementation((...call: unknown[]) => {
|
||||
return (call[1] as readonly string[]).includes('--config.lockfile=false') ? repair.child : base(...call)
|
||||
if (!(call[1] as readonly string[]).includes('--config.lockfile=false')) return base(...call)
|
||||
repair.spawned()
|
||||
return repair.child
|
||||
})
|
||||
const outcome = await runProfilePnpm(context, ['add', 'incompatible'], { execution: 'service', outputBytes: 8192 })
|
||||
expect(outcome.exitCode).toBe(1)
|
||||
|
||||
@@ -101,6 +101,67 @@
|
||||
line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px));
|
||||
}
|
||||
|
||||
.running {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
--dsh-chat-flow-gap: 12px;
|
||||
color: var(--dsw-alias-label-deep-diving);
|
||||
font-size: calc(var(--dsh-content-font-size, 14px) - 2px);
|
||||
line-height: calc(22px + var(--dsh-content-font-delta, 0px));
|
||||
}
|
||||
|
||||
/* The last visible row before the status must contain output, not an input.
|
||||
Hidden and empty seats do not separate a user message from the status. */
|
||||
.column > :nth-last-child(2 of :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)))):is(
|
||||
.flowItem:not([data-chat-flow-kind="user"]):not([data-chat-flow-kind="steering"]):not([data-chat-flow-kind="turn-trigger"]),
|
||||
[data-chat-group-key]
|
||||
) ~ .running > .runningDivider {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.runningDivider {
|
||||
display: none;
|
||||
width: 100%;
|
||||
height: 0.5px;
|
||||
margin: 8px 0 10px;
|
||||
background: color-mix(in srgb, var(--dsw-alias-border-l1) 75%, var(--dsw-alias-border-l2));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.runningContent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.runningText {
|
||||
min-width: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.runningIcon {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
width: calc(14px + var(--dsh-content-font-delta, 0px));
|
||||
height: calc(14px + var(--dsh-content-font-delta, 0px));
|
||||
}
|
||||
|
||||
.runningWhaleStill {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.runningWhaleAnimated {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.runningWhaleStill {
|
||||
display: initial;
|
||||
}
|
||||
}
|
||||
|
||||
.openError {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { ChatGroupSeat } from './ChatGroupSeat.tsx'
|
||||
import { chatRenderKey } from './render-entry.ts'
|
||||
import { assertNever } from '@deepseek-ai/dsh-util-values'
|
||||
import { TurnNavigator } from './TurnNavigator.tsx'
|
||||
import { RunningStatus } from './RunningStatus.tsx'
|
||||
import { mergeTurnRailItems } from './turn-rail-items.ts'
|
||||
import { useChatScroll } from './use-chat-scroll.ts'
|
||||
import { fileMediaUrl, resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
@@ -111,6 +112,13 @@ export function ChatView({
|
||||
// both the data and its change signal: the array identity moves only when a
|
||||
// Turn enters, leaves, or changes its preview.
|
||||
const turnNavigationItems = useChat(s => s.navigation.items())
|
||||
const latestTurnAnchor = turnNavigationItems.at(-1)?.anchorKey
|
||||
const runningStartTime = useChatNode(latestTurnAnchor ?? '', (node) => {
|
||||
const location = node?.location
|
||||
return location?.kind === 'turn' || location?.kind === 'step'
|
||||
? location.turn.status === 'open' ? location.turn.start?.time : undefined
|
||||
: undefined
|
||||
})
|
||||
// Host-computed whole-log outline; the merge is view-layer only (the
|
||||
// conversation snapshot never carries projection values).
|
||||
const turnOutline = useProjection('turnOutline')
|
||||
@@ -269,6 +277,7 @@ export function ChatView({
|
||||
t={t}
|
||||
/>
|
||||
</MarkdownDelegateProvider>
|
||||
{running && <RunningStatus startTime={runningStartTime} t={t} />}
|
||||
{/* No pending placeholders: questions (ui-user-questions) and approvals
|
||||
(ApprovalPanel) both take over the composer, so a flow card would
|
||||
double-render the same wait. */}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/** Running Turn clock isolated from the transcript's render cycle. */
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { TextShimmer } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { formatLiveRunDuration, LIVE_RUN_CLOCK_INTERVAL_MS } from './message-chrome.ts'
|
||||
import { RunningWhaleTail } from './RunningWhaleTail.tsx'
|
||||
import a11yCss from './accessibility.module.css'
|
||||
import css from './ChatView.module.css'
|
||||
|
||||
interface RunningStatusProps {
|
||||
readonly startTime: number | undefined
|
||||
readonly t: ChatViewSlotProps['t']
|
||||
}
|
||||
|
||||
/**
|
||||
* Show live elapsed time after the current Turn's content without announcing ticks.
|
||||
* @param props - Current Turn start time and localized copy.
|
||||
* @returns the blue running indicator; mount only while the Session is running.
|
||||
*/
|
||||
export const RunningStatus = memo(function RunningStatus({ startTime, t }: RunningStatusProps) {
|
||||
const [now, setNow] = useState(Date.now)
|
||||
useEffect(() => {
|
||||
if (startTime === undefined) return
|
||||
setNow(Date.now())
|
||||
const timer = setInterval(() => { setNow(Date.now()) }, LIVE_RUN_CLOCK_INTERVAL_MS)
|
||||
return () => { clearInterval(timer) }
|
||||
}, [startTime])
|
||||
const label = startTime === undefined ? t('chat.deepDiving') : t('chat.deepDivingFor', {
|
||||
duration: formatLiveRunDuration(Math.max(1000, now - startTime), t),
|
||||
})
|
||||
return (
|
||||
<div className={css.running} data-chat-running>
|
||||
<span className={a11yCss.visuallyHidden} role="status" aria-live="polite" aria-atomic="true">{t('chat.deepDiving')}</span>
|
||||
<span className={css.runningDivider} aria-hidden="true" />
|
||||
<span className={css.runningContent}>
|
||||
<RunningWhaleTail />
|
||||
<TextShimmer active className={css.runningText}>{label}</TextShimmer>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
/** Native SVG contour animation for the whale Chat shows while the Session runs. */
|
||||
import css from './ChatView.module.css'
|
||||
|
||||
const REST_PATH = 'M8.844 13.742C8.967 12.328 8.45 10.4 8.45 9.65C8.45 8.94 8.88 8.43 9.6 8.43C11.285 8.43 12.106 8.281 12.685 8.104C13.71 7.791 14.585 6.768 15.055 5.945C15.137 5.803 14.99 5.641 14.829 5.671C13.829 5.86 12.828 5.376 11.827 4.978C10.659 4.514 9.491 4.707 8.935 4.876C8.805 4.915 8.658 4.819 8.636 4.686C8.468 3.643 7.405 2.615 5.498 2.238C4.54 2.048 3.748 1.574 3.347 1.202C3.252 1.113 3.088 1.125 3.03 1.242C2.628 2.059 2.168 3.82 5.248 6.115C5.82 6.494 6.31 6.785 6.574 7.637C6.72 8.104 6.157 9.168 6.061 9.368C5.157 11.27 5.089 12.19 4.926 13.742'
|
||||
|
||||
/** Same cubic segments in every pose keep both tail-base endpoints stationary. */
|
||||
const MOTION_PATHS = [
|
||||
REST_PATH,
|
||||
REST_PATH,
|
||||
'M8.844 13.742C8.967 12.328 8.453 10.404 8.455 9.655C8.457 8.946 8.89 8.438 9.61 8.441C11.295 8.448 12.116 8.302 12.696 8.127C13.722 7.818 14.601 6.796 15.074 5.97C15.156 5.827 15.01 5.663 14.849 5.693C13.848 5.881 12.848 5.391 11.849 4.989C10.681 4.521 9.513 4.712 8.956 4.88C8.827 4.919 8.679 4.823 8.658 4.689C8.492 3.645 7.429 2.615 5.523 2.237C4.565 2.047 3.773 1.572 3.373 1.201C3.277 1.112 3.113 1.124 3.056 1.241C2.653 2.057 2.193 3.808 5.267 6.11C5.837 6.49 6.326 6.782 6.587 7.636C6.73 8.103 6.164 9.165 6.068 9.365C5.157 11.265 5.089 12.19 4.926 13.742',
|
||||
'M8.844 13.742C8.967 12.328 8.476 10.437 8.498 9.697C8.519 8.997 8.969 8.506 9.689 8.533C11.373 8.596 12.198 8.481 12.784 8.327C13.82 8.055 14.734 7.049 15.232 6.219C15.318 6.075 15.177 5.903 15.015 5.93C14.009 6.092 13.025 5.562 12.037 5.128C10.883 4.62 9.709 4.785 9.148 4.939C9.018 4.975 8.873 4.874 8.856 4.739C8.717 3.685 7.676 2.629 5.778 2.213C4.824 2.004 4.041 1.515 3.649 1.136C3.555 1.045 3.391 1.054 3.331 1.17C2.912 1.978 2.423 3.678 5.425 6.063C5.984 6.458 6.463 6.766 6.695 7.627C6.823 8.099 6.221 9.144 6.12 9.342C5.154 11.226 5.089 12.19 4.926 13.742',
|
||||
'M8.844 13.742C8.967 12.328 8.473 10.432 8.492 9.69C8.51 8.989 8.957 8.495 9.676 8.519C11.361 8.573 12.186 8.453 12.771 8.298C13.805 8.023 14.714 7.034 15.213 6.234C15.299 6.095 15.158 5.929 14.996 5.954C13.99 6.106 13.007 5.59 12.021 5.158C10.87 4.655 9.696 4.804 9.134 4.952C9.003 4.987 8.859 4.886 8.842 4.752C8.712 3.706 7.688 2.639 5.797 2.19C4.847 1.964 4.073 1.459 3.688 1.072C3.595 0.98 3.431 0.986 3.369 1.101C2.936 1.901 2.407 3.656 5.406 6.06C5.964 6.459 6.443 6.767 6.679 7.628C6.808 8.099 6.212 9.147 6.111 9.346C5.155 11.233 5.089 12.19 4.926 13.742',
|
||||
'M8.844 13.742C8.967 12.328 8.457 10.41 8.463 9.662C8.469 8.955 8.904 8.45 9.624 8.458C11.309 8.475 12.131 8.335 12.713 8.167C13.741 7.87 14.627 6.885 15.113 6.112C15.198 5.978 15.055 5.821 14.893 5.846C13.889 6.004 12.898 5.52 11.908 5.109C10.752 4.629 9.579 4.78 9.019 4.932C8.889 4.967 8.744 4.868 8.726 4.736C8.587 3.701 7.565 2.64 5.676 2.183C4.726 1.954 3.954 1.444 3.57 1.055C3.479 0.962 3.314 0.967 3.252 1.082C2.815 1.881 2.27 3.708 5.305 6.084C5.87 6.477 6.353 6.777 6.607 7.633C6.747 8.102 6.174 9.161 6.077 9.361C5.156 11.258 5.089 12.19 4.926 13.742',
|
||||
'M8.844 13.742C8.967 12.328 8.425 10.366 8.403 9.607C8.383 8.888 8.794 8.361 9.513 8.335C11.197 8.276 12.012 8.096 12.585 7.897C13.598 7.547 14.436 6.515 14.883 5.708C14.961 5.569 14.81 5.417 14.65 5.45C13.655 5.661 12.641 5.221 11.631 4.858C10.452 4.434 9.289 4.648 8.737 4.828C8.608 4.871 8.459 4.779 8.434 4.647C8.242 3.618 7.163 2.612 5.252 2.261C4.29 2.084 3.492 1.618 3.087 1.252C2.99 1.164 2.826 1.178 2.77 1.296C2.378 2.118 1.937 3.949 5.086 6.167C5.669 6.531 6.167 6.806 6.461 7.649C6.623 8.111 6.097 9.191 6.008 9.393C5.161 11.31 5.089 12.19 4.926 13.742',
|
||||
'M8.844 13.742C8.967 12.328 8.426 10.368 8.407 9.61C8.388 8.892 8.8 8.365 9.52 8.342C11.204 8.286 12.019 8.107 12.592 7.907C13.605 7.553 14.443 6.483 14.878 5.614C14.954 5.464 14.8 5.302 14.641 5.34C13.649 5.577 12.629 5.12 11.611 4.759C10.422 4.338 9.264 4.59 8.716 4.786C8.589 4.832 8.437 4.742 8.409 4.608C8.193 3.567 7.074 2.592 5.15 2.321C4.183 2.185 3.365 1.758 2.945 1.409C2.845 1.326 2.682 1.347 2.631 1.467C2.275 2.305 1.917 4.029 5.086 6.183C5.673 6.536 6.174 6.807 6.469 7.649C6.63 8.11 6.101 9.189 6.012 9.391C5.16 11.307 5.089 12.19 4.926 13.742',
|
||||
'M8.844 13.742C8.967 12.328 8.439 10.385 8.43 9.631C8.421 8.917 8.843 8.399 9.563 8.388C11.248 8.362 12.066 8.198 12.642 8.008C13.661 7.672 14.517 6.601 14.963 5.717C15.04 5.564 14.889 5.396 14.729 5.433C13.735 5.663 12.719 5.181 11.705 4.804C10.52 4.364 9.36 4.611 8.81 4.803C8.682 4.847 8.531 4.755 8.505 4.621C8.297 3.572 7.181 2.589 5.257 2.315C4.29 2.178 3.473 1.751 3.053 1.402C2.953 1.319 2.79 1.34 2.739 1.46C2.382 2.297 2.024 3.975 5.165 6.16C5.747 6.519 6.244 6.796 6.524 7.643C6.678 8.106 6.131 9.178 6.038 9.379C5.159 11.288 5.089 12.19 4.926 13.742',
|
||||
'M8.844 13.742C8.967 12.328 8.462 10.417 8.472 9.671C8.481 8.965 8.921 8.464 9.64 8.476C11.325 8.505 12.149 8.371 12.731 8.204C13.761 7.908 14.653 6.889 15.135 6.057C15.218 5.913 15.074 5.746 14.913 5.775C13.91 5.954 12.916 5.448 11.919 5.034C10.757 4.551 9.586 4.735 9.028 4.899C8.898 4.937 8.752 4.839 8.732 4.705C8.575 3.656 7.517 2.618 5.613 2.231C4.655 2.037 3.865 1.56 3.467 1.186C3.372 1.097 3.208 1.108 3.149 1.225C2.743 2.039 2.278 3.763 5.327 6.093C5.893 6.478 6.379 6.776 6.629 7.632C6.766 8.101 6.186 9.157 6.088 9.356C5.156 11.25 5.089 12.19 4.926 13.742',
|
||||
'M8.844 13.742C8.967 12.328 8.463 10.418 8.474 9.673C8.485 8.968 8.925 8.468 9.645 8.482C11.33 8.513 12.153 8.381 12.736 8.218C13.766 7.929 14.661 6.94 15.152 6.153C15.237 6.017 15.095 5.856 14.933 5.881C13.928 6.038 12.94 5.541 11.951 5.122C10.796 4.633 9.623 4.786 9.062 4.937C8.932 4.972 8.787 4.873 8.77 4.739C8.633 3.699 7.608 2.638 5.717 2.189C4.766 1.963 3.993 1.457 3.607 1.07C3.515 0.977 3.351 0.983 3.288 1.098C2.855 1.899 2.321 3.692 5.344 6.075C5.906 6.47 6.388 6.773 6.635 7.631C6.771 8.101 6.189 9.155 6.09 9.355C5.156 11.248 5.089 12.19 4.926 13.742',
|
||||
'M8.844 13.742C8.967 12.328 8.436 10.381 8.425 9.626C8.414 8.911 8.833 8.391 9.553 8.377C11.238 8.344 12.055 8.178 12.631 7.987C13.649 7.65 14.502 6.601 14.953 5.753C15.031 5.607 14.88 5.446 14.72 5.48C13.725 5.696 12.712 5.229 11.702 4.853C10.522 4.414 9.359 4.64 8.807 4.824C8.679 4.867 8.529 4.774 8.504 4.641C8.308 3.599 7.214 2.601 5.297 2.282C4.333 2.122 3.527 1.674 3.115 1.315C3.017 1.229 2.854 1.246 2.799 1.365C2.423 2.193 2.02 3.939 5.152 6.154C5.733 6.518 6.229 6.797 6.512 7.644C6.666 8.107 6.124 9.18 6.032 9.382C5.159 11.292 5.089 12.19 4.926 13.742',
|
||||
'M8.844 13.742C8.967 12.328 8.449 10.399 8.448 9.648C8.447 8.938 8.876 8.427 9.596 8.426C11.282 8.423 12.102 8.272 12.681 8.092C13.705 7.774 14.577 6.731 15.041 5.876C15.121 5.728 14.973 5.562 14.812 5.595C13.813 5.8 12.808 5.309 11.802 4.916C10.628 4.456 9.462 4.671 8.909 4.849C8.78 4.89 8.631 4.795 8.608 4.661C8.425 3.613 7.339 2.602 5.424 2.27C4.461 2.103 3.658 1.649 3.249 1.287C3.152 1.201 2.988 1.217 2.933 1.335C2.55 2.161 2.137 3.872 5.235 6.128C5.81 6.5 6.302 6.787 6.569 7.638C6.716 8.104 6.154 9.168 6.059 9.369C5.157 11.272 5.089 12.19 4.926 13.742',
|
||||
'M8.844 13.742C8.967 12.328 8.472 10.431 8.491 9.69C8.509 8.988 8.956 8.494 9.676 8.517C11.36 8.571 12.185 8.451 12.77 8.294C13.804 8.016 14.712 7.011 15.207 6.188C15.293 6.046 15.151 5.876 14.989 5.903C13.984 6.066 12.998 5.545 12.008 5.115C10.853 4.614 9.68 4.779 9.119 4.934C8.989 4.97 8.844 4.869 8.826 4.735C8.686 3.684 7.646 2.629 5.748 2.211C4.793 2.001 4.012 1.511 3.62 1.13C3.526 1.039 3.362 1.048 3.302 1.164C2.882 1.971 2.388 3.691 5.399 6.068C5.96 6.463 6.44 6.768 6.677 7.628C6.807 8.099 6.211 9.147 6.111 9.346C5.155 11.233 5.089 12.19 4.926 13.742',
|
||||
'M8.844 13.742C8.967 12.328 8.47 10.428 8.487 9.686C8.503 8.983 8.949 8.488 9.668 8.509C11.353 8.558 12.178 8.435 12.762 8.279C13.795 8.002 14.701 7.021 15.2 6.237C15.286 6.102 15.145 5.939 14.983 5.963C13.977 6.11 12.994 5.602 12.009 5.173C10.859 4.672 9.685 4.813 9.122 4.959C8.992 4.993 8.848 4.892 8.832 4.758C8.705 3.717 7.69 2.645 5.804 2.178C4.855 1.943 4.087 1.43 3.705 1.039C3.613 0.946 3.449 0.95 3.386 1.064C2.945 1.861 2.395 3.646 5.393 6.06C5.951 6.46 6.43 6.768 6.668 7.628C6.799 8.1 6.206 9.149 6.106 9.348C5.155 11.237 5.089 12.19 4.926 13.742',
|
||||
'M8.844 13.742C8.967 12.328 8.457 10.409 8.462 9.662C8.468 8.954 8.903 8.449 9.623 8.456C11.308 8.472 12.13 8.332 12.711 8.164C13.739 7.866 14.625 6.882 15.111 6.111C15.195 5.978 15.052 5.821 14.891 5.846C13.886 6.003 12.896 5.521 11.906 5.11C10.75 4.63 9.577 4.781 9.017 4.932C8.886 4.967 8.742 4.869 8.724 4.736C8.585 3.702 7.564 2.64 5.675 2.182C4.725 1.952 3.954 1.441 3.571 1.052C3.479 0.959 3.315 0.964 3.252 1.078C2.815 1.877 2.267 3.708 5.303 6.084C5.867 6.477 6.351 6.777 6.606 7.633C6.745 8.102 6.173 9.161 6.076 9.361C5.156 11.259 5.089 12.19 4.926 13.742',
|
||||
'M8.844 13.742C8.967 12.328 8.432 10.376 8.418 9.62C8.404 8.904 8.82 8.382 9.54 8.364C11.225 8.323 12.041 8.152 12.616 7.961C13.633 7.623 14.483 6.603 14.94 5.806C15.019 5.669 14.87 5.516 14.71 5.547C13.712 5.744 12.704 5.295 11.699 4.92C10.525 4.482 9.359 4.68 8.805 4.853C8.676 4.894 8.527 4.801 8.504 4.668C8.325 3.638 7.262 2.618 5.355 2.239C4.397 2.048 3.605 1.57 3.206 1.198C3.11 1.109 2.946 1.12 2.888 1.238C2.485 2.054 2.016 3.888 5.137 6.146C5.716 6.517 6.211 6.799 6.496 7.645C6.652 8.109 6.115 9.184 6.024 9.385C5.159 11.298 5.089 12.19 4.926 13.742',
|
||||
'M8.844 13.742C8.967 12.328 8.429 10.371 8.411 9.614C8.394 8.897 8.808 8.372 9.528 8.351C11.212 8.301 12.028 8.125 12.602 7.927C13.616 7.578 14.458 6.518 14.899 5.663C14.975 5.515 14.823 5.354 14.664 5.391C13.67 5.619 12.653 5.161 11.638 4.796C10.453 4.37 9.293 4.61 8.743 4.801C8.616 4.846 8.465 4.755 8.438 4.622C8.23 3.582 7.123 2.596 5.202 2.303C4.236 2.156 3.424 1.718 3.007 1.364C2.908 1.28 2.745 1.299 2.692 1.419C2.326 2.252 1.947 3.995 5.104 6.173C5.689 6.531 6.188 6.804 6.479 7.648C6.639 8.11 6.107 9.187 6.017 9.389C5.16 11.304 5.089 12.19 4.926 13.742',
|
||||
'M8.844 13.742C8.967 12.328 8.438 10.384 8.428 9.629C8.419 8.915 8.839 8.397 9.559 8.384C11.244 8.356 12.062 8.191 12.638 8.001C13.657 7.664 14.512 6.597 14.959 5.721C15.036 5.57 14.884 5.403 14.725 5.44C13.73 5.667 12.715 5.189 11.701 4.813C10.518 4.373 9.357 4.616 8.807 4.806C8.679 4.851 8.528 4.759 8.502 4.625C8.296 3.577 7.184 2.591 5.262 2.309C4.295 2.167 3.481 1.736 3.062 1.385C2.962 1.301 2.799 1.321 2.748 1.441C2.387 2.276 2.02 3.969 5.16 6.159C5.742 6.519 6.239 6.797 6.52 7.643C6.674 8.107 6.128 9.179 6.036 9.38C5.159 11.289 5.089 12.19 4.926 13.742',
|
||||
'M8.844 13.742C8.967 12.328 8.45 10.4 8.45 9.65C8.451 8.941 8.881 8.431 9.601 8.431C11.286 8.432 12.107 8.283 12.686 8.107C13.711 7.796 14.587 6.785 15.061 5.98C15.143 5.841 14.997 5.681 14.836 5.71C13.835 5.89 12.836 5.41 11.838 5.011C10.673 4.544 9.503 4.725 8.946 4.89C8.816 4.928 8.67 4.832 8.649 4.698C8.488 3.659 7.437 2.622 5.536 2.221C4.579 2.02 3.793 1.534 3.398 1.157C3.303 1.067 3.139 1.077 3.08 1.193C2.667 2.005 2.182 3.794 5.253 6.109C5.824 6.491 6.312 6.784 6.576 7.636C6.72 8.104 6.157 9.167 6.062 9.368C5.157 11.27 5.089 12.19 4.926 13.742',
|
||||
REST_PATH,
|
||||
REST_PATH,
|
||||
].join(';')
|
||||
const MOTION_TIMES = '0;0.028571;0.057143;0.095238;0.114286;0.133333;0.171429;0.2;0.219048;0.257143;0.285714;0.380952;0.409524;0.447619;0.47619;0.495238;0.52381;0.552381;0.571429;0.638095;0.8;1'
|
||||
|
||||
/**
|
||||
* Loop the tail every three seconds after a 0.3s delay.
|
||||
* @returns animated and resting contours switched by the reduced-motion stylesheet.
|
||||
*/
|
||||
export function RunningWhaleTail() {
|
||||
return (
|
||||
<span className={css.runningIcon} aria-hidden="true">
|
||||
<svg width="100%" height="100%" viewBox="0 0 16 16" fill="none">
|
||||
<path className={css.runningWhaleAnimated} d={REST_PATH} stroke="currentColor" strokeWidth={1}>
|
||||
<animate attributeName="d" values={MOTION_PATHS} keyTimes={MOTION_TIMES}
|
||||
calcMode="linear" begin="0.3s" dur="3s" repeatCount="indefinite" />
|
||||
</path>
|
||||
<path className={css.runningWhaleStill} d={REST_PATH} stroke="currentColor" strokeWidth={1} />
|
||||
</svg>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { memo } from 'react'
|
||||
import { IconChevronDownOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatNodeViewProps } from '../contract/slots.ts'
|
||||
import { turnProcessAlwaysOpen } from '../contract/turn-process.ts'
|
||||
import { formatLiveRunDuration, formatRunDuration, LIVE_RUN_CLOCK_INTERVAL_MS } from './message-chrome.ts'
|
||||
import { formatRunDuration } from './message-chrome.ts'
|
||||
import a11yCss from './accessibility.module.css'
|
||||
import css from './TurnProcessNodeView.module.css'
|
||||
|
||||
@@ -15,33 +15,21 @@ export const TurnProcessNodeView = memo(function TurnProcessNodeView({
|
||||
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
|
||||
? node.location.turn
|
||||
: undefined
|
||||
const [now, setNow] = useState(Date.now)
|
||||
const ticking = turn?.status === 'open' && turn.start !== undefined
|
||||
useEffect(() => {
|
||||
if (!ticking) return
|
||||
setNow(Date.now())
|
||||
const timer = setInterval(() => { setNow(Date.now()) }, LIVE_RUN_CLOCK_INTERVAL_MS)
|
||||
return () => { clearInterval(timer) }
|
||||
}, [ticking])
|
||||
if (turn?.start === undefined && turn?.status !== 'closed') return null
|
||||
if (turn?.status !== 'closed') return null
|
||||
const canCollapse = turnProcess.foldable && turnProcess.hasContent && !turnProcessAlwaysOpen(node)
|
||||
const running = turn.status === 'open'
|
||||
const reason = turn.end?.data.reason.kind
|
||||
const elapsedMs = turn.start === undefined ? undefined
|
||||
: Math.max(1000, (turn.end?.time ?? now) - turn.start.time)
|
||||
const elapsedMs = turn.start === undefined || turn.end === undefined ? undefined
|
||||
: Math.max(1000, turn.end.time - turn.start.time)
|
||||
const duration = elapsedMs === undefined ? undefined
|
||||
: running ? formatLiveRunDuration(elapsedMs, t) : formatRunDuration(elapsedMs, t)
|
||||
: formatRunDuration(elapsedMs, t)
|
||||
// Other end reasons retain elapsed time; only cancellation and failure replace it.
|
||||
const label = running
|
||||
? duration === undefined ? t('chat.deepDiving') : t('message.turnProcess.deepDivingFor', { duration })
|
||||
: reason === 'aborted' ? t('message.stopped')
|
||||
: reason === 'error' ? t('message.turnProcess.failed')
|
||||
: duration === undefined ? t('message.turnProcess.worked')
|
||||
: t('message.turnProcess.took', { duration })
|
||||
const announcement = running ? t('chat.deepDiving')
|
||||
: reason === 'aborted' ? t('message.stopped')
|
||||
: reason === 'error' ? t('message.turnProcess.failed')
|
||||
: t('message.turnProcess.worked')
|
||||
const label = reason === 'aborted' ? t('message.stopped')
|
||||
: reason === 'error' ? t('message.turnProcess.failed')
|
||||
: duration === undefined ? t('message.turnProcess.worked')
|
||||
: t('message.turnProcess.took', { duration })
|
||||
const announcement = reason === 'aborted' ? t('message.stopped')
|
||||
: reason === 'error' ? t('message.turnProcess.failed')
|
||||
: t('message.turnProcess.worked')
|
||||
return (
|
||||
<>
|
||||
<span className={a11yCss.visuallyHidden} role="status" aria-live="polite" aria-atomic="true">{announcement}</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** An independent, expandable notice explaining a non-human Turn trigger. */
|
||||
import { useId, useState, type ComponentType } from 'react'
|
||||
import {
|
||||
IconAgentPresetOutlineRegular, IconAlarmClockOutlineRegular, IconBranchOutlineRegular,
|
||||
IconAgentPresetOutlineRegular, IconClockOutlineRegular, IconBranchOutlineRegular,
|
||||
IconChevronDownOutlineRegular, IconContextInjectionOutlineRegular, IconCordisPluginOutlineRegular,
|
||||
IconGoalOutlineRegular, IconGlobeOutlineRegular, IconPaperPlaneOutlineRegular, IconQueueOutlineRegular,
|
||||
type IconProps,
|
||||
@@ -20,7 +20,7 @@ const TRIGGER_ICONS: Record<TurnTriggerIcon, ComponentType<IconProps>> = {
|
||||
subagent: IconAgentPresetOutlineRegular,
|
||||
github: IconBranchOutlineRegular,
|
||||
webhook: IconGlobeOutlineRegular,
|
||||
schedule: IconAlarmClockOutlineRegular,
|
||||
schedule: IconClockOutlineRegular,
|
||||
job: IconQueueOutlineRegular,
|
||||
plugin: IconCordisPluginOutlineRegular,
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
en: 91b35cbebe8c2a1d
|
||||
zh: a4fee6d7102bc874
|
||||
/chat-conversation-node-rules/whole-turn-folding:
|
||||
en: 8db4991626210708
|
||||
zh: e18c8aa3cb434980
|
||||
en: 69989cc6a42c137a
|
||||
zh: ceca703426315b88
|
||||
/chat-conversation-node-rules/display-modes:
|
||||
en: ada5a60d2c46f6a8
|
||||
zh: f3eaeb5cb4d9b3de
|
||||
|
||||
@@ -78,7 +78,7 @@ The Turn control follows all its opening inputs, including human steering and no
|
||||
| Non-human message in an idle `next-step` claim | Turn-trigger notice only in Step 1, with a loaded Turn start, a claim after that start, no `next-turn` claim in this Turn, and no human in the same `next-step` batch. |
|
||||
| Other non-human input | Ordinary Context, retained in the Node Store but omitted from Chat. Unclaimed, canceled, or requeued messages do not establish a waking claim. |
|
||||
|
||||
Trigger titles and icons use the recorded `source.kind`: `schedule`, `tool-jobs`, and `cordis-host-runner` identify scheduled work, background work, and plugin updates. Goal, agent, team, subagent, and webhook sources have their own titles; a webhook with `provider: github` uses the GitHub title. Unknown sources use the generic execution-request title. Expanding the notice shows its recorded body; it does not imply successful execution.
|
||||
Trigger titles and icons use the recorded `source.kind`: `schedule`, `tool-jobs`, and `cordis-host-runner` identify scheduled work, background work, and plugin updates. Schedule notices are labeled Automation task and use the same plain clock icon as the Automation tasks entry. Goal, agent, team, subagent, and webhook sources have their own titles; a webhook with `provider: github` uses the GitHub title. Unknown sources use the generic execution-request title. Expanding the notice shows its recorded body; it does not imply successful execution.
|
||||
|
||||
| Turn condition | Current behavior |
|
||||
|---|---|
|
||||
@@ -99,7 +99,7 @@ Clicking Load older anchors the first visible content item below that button in
|
||||
|
||||
Paging adds older content above the retained anchor without jumping to the new top. A capped group absorbs the displacement within its scroll range; the outer transcript absorbs the remainder, including when the group first reaches its cap. If the available scroll range is insufficient, compensation stops at the actual limit without adding bottom space. Later content growth keeps the same anchor until a reading gesture or explicit navigation releases it. Typing or clicking within the composer and non-scrolling transcript keys retain the anchor. If the reader scrolls while a page is loading, scrolling takes priority and its settled reading position becomes the new paging anchor.
|
||||
|
||||
A running clock updates in whole seconds, starts at one second, and uses hours from 60 minutes. Completion fixes the duration; cancellation and failure replace it with their status. Lifecycle changes have a polite announcement; clock ticks do not. This control is Chat's only Turn-level running indicator.
|
||||
While the Session runs, Chat appends a Session-level indicator at the bottom of the current transcript, after pending submission or steering rows: a whale tail beside shimmering elapsed time. Before the current Turn start time arrives, the indicator shows status text without a duration. The clock updates in whole seconds, starts at one second, and uses hours from 60 minutes. When the Session stops running, the indicator disappears; the closed Turn process control shows the fixed duration, cancellation, or failure status. Lifecycle changes have a polite announcement; clock ticks do not. The whale is hidden from assistive technology and remains static under reduced motion.
|
||||
|
||||
Automatic collapse keeps the process open if hiding it would hide keyboard focus. Manual closing focuses the process control before hiding its members. Closing a whole Turn resets its groups and inner reasoning/tool disclosures; it does not reset unrelated renderer state. Browser find can reveal searchable hidden content.
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
|
||||
| 空闲时从 `next-step` 领取的非人工消息 | 仅在 Step 1、已加载轮次起点、领取发生于起点之后、本轮没有 `next-turn` 领取、同一 `next-step` 批次没有人工消息时,作为轮次触发通知。 |
|
||||
| 其他非人工输入 | 普通 Context,保留在 Node Store,但不显示在 Chat 中。未领取、已取消或重新入队的消息不构成唤醒领取。 |
|
||||
|
||||
触发通知的标题与图标使用记录中的 `source.kind`:`schedule`、`tool-jobs`、`cordis-host-runner` 分别表示定时任务、后台任务和插件更新。目标、Agent、团队、子任务和 webhook 来源有各自的标题;`provider: github` 的 webhook 使用 GitHub 标题。未知来源显示通用执行请求标题。展开通知显示记录的正文,不代表执行成功。
|
||||
触发通知的标题与图标使用记录中的 `source.kind`:`schedule`、`tool-jobs`、`cordis-host-runner` 分别表示定时任务、后台任务和插件更新。定时任务通知显示为「自动化任务」,与「自动化任务」入口使用同一个普通时钟图标。目标、Agent、团队、子任务和 webhook 来源有各自的标题;`provider: github` 的 webhook 使用 GitHub 标题。未知来源显示通用执行请求标题。展开通知显示记录的正文,不代表执行成功。
|
||||
|
||||
| 轮次条件 | 当前行为 |
|
||||
|---|---|
|
||||
@@ -99,7 +99,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
|
||||
|
||||
分页把更早内容加到保留的锚点上方,不主动跳到新内容顶部。限高组先在自己的滚动范围内吸收位移,剩余部分由外层文本滚动补偿,包括组正文刚达到限高的情况。可滚动范围不足时,补偿停在实际边界,不增加底部空白。后续内容撑高沿用同一个锚点,直到阅读手势或显式导航释放它。在 composer 中打字或点击,以及 transcript 内的非滚动按键,保留锚点。分页请求期间若读者滚动,优先服从滚动,停止后以新的阅读位置作为分页锚点。
|
||||
|
||||
运行时钟按整秒更新,最少显示一秒,满 60 分钟使用小时。完成后固定时长,取消与失败使用对应状态替代。生命周期变化有礼貌播报,时钟跳动不播报。该控件是 Chat 唯一的轮次级运行指示。
|
||||
会话运行期间,Chat 在当前 transcript 最底部、待提交或 steering 行之后附加 Session 级运行指示:鲸鱼摆尾旁显示闪烁的已用时间。当前 Turn 的开始时间到达前,运行指示只显示状态文字,不显示时长。时钟按整秒更新,最少显示一秒,满 60 分钟使用小时。会话停止运行后,该指示消失;已结束 Turn 的过程控件显示固定时长、取消或失败状态。生命周期变化有礼貌播报,时钟跳动不播报。鲸鱼对辅助技术隐藏,启用减少动态效果时保持静态。
|
||||
|
||||
自动收起若会隐藏键盘焦点,则保持过程展开。手动收起先将焦点移到过程控件,再隐藏成员。收起整轮会重置内部组及推理、工具的开合,不重置渲染器的其他状态。浏览器查找可以显示可搜索的隐藏内容。
|
||||
|
||||
|
||||
@@ -57,12 +57,11 @@ export const zh = {
|
||||
'message.trigger.subagent': '子任务状态更新',
|
||||
'message.trigger.github': '收到 GitHub 事件',
|
||||
'message.trigger.webhook': '收到外部事件',
|
||||
'message.trigger.schedule': '定时任务',
|
||||
'message.trigger.schedule': '自动化任务',
|
||||
'message.trigger.job': '后台任务状态更新',
|
||||
'message.trigger.plugin': '插件状态更新',
|
||||
'message.trigger.explanation': '这条通知触发了本轮回复。',
|
||||
'message.turnProcess.worked': '已完成工作',
|
||||
'message.turnProcess.deepDivingFor': '深度求索中,用时{duration}',
|
||||
'message.turnProcess.took': '用时 {duration}',
|
||||
'message.turnProcess.failed': '处理失败',
|
||||
'view.chat': '对话',
|
||||
@@ -87,7 +86,8 @@ export const zh = {
|
||||
'chat.loadError': '历史加载失败:{message}({code})',
|
||||
'chat.loadOlder': '加载更早',
|
||||
'chat.toBottom': '回到底部',
|
||||
'chat.deepDiving': '深度求索中',
|
||||
'chat.deepDiving': '深度求索中...',
|
||||
'chat.deepDivingFor': '深度求索中,用时 {duration}...',
|
||||
'chat.turnNavigation.label': '轮次导航',
|
||||
'chat.turnNavigation.jump': '跳转到第 {turn} 轮',
|
||||
'chat.turnNavigation.jumpLoad': '加载并跳转到第 {turn} 轮',
|
||||
@@ -250,12 +250,11 @@ export const en = {
|
||||
'message.trigger.subagent': 'Subtask status updated',
|
||||
'message.trigger.github': 'GitHub event received',
|
||||
'message.trigger.webhook': 'External event received',
|
||||
'message.trigger.schedule': 'Scheduled task',
|
||||
'message.trigger.schedule': 'Automation task',
|
||||
'message.trigger.job': 'Background task updated',
|
||||
'message.trigger.plugin': 'Plugin status updated',
|
||||
'message.trigger.explanation': 'This notification triggered this response.',
|
||||
'message.turnProcess.worked': 'Worked',
|
||||
'message.turnProcess.deepDivingFor': 'Deep diving for {duration}',
|
||||
'message.turnProcess.took': 'Took {duration}',
|
||||
'message.turnProcess.failed': 'Failed',
|
||||
'view.chat': 'Chat',
|
||||
@@ -281,6 +280,7 @@ export const en = {
|
||||
'chat.loadOlder': 'Load earlier',
|
||||
'chat.toBottom': 'Back to bottom',
|
||||
'chat.deepDiving': 'Deep diving...',
|
||||
'chat.deepDivingFor': 'Deep diving for {duration}...',
|
||||
'chat.turnNavigation.label': 'Turn navigation',
|
||||
'chat.turnNavigation.jump': 'Jump to turn {turn}',
|
||||
'chat.turnNavigation.jumpLoad': 'Load and jump to turn {turn}',
|
||||
|
||||
@@ -505,6 +505,10 @@ function turnProcessControl(container: HTMLElement): HTMLButtonElement | null {
|
||||
return container.querySelector<HTMLButtonElement>('[data-turn-process]')
|
||||
}
|
||||
|
||||
function runningContent(container: HTMLElement): HTMLElement | null {
|
||||
return container.querySelector<HTMLElement>('[data-chat-running] > :last-child')
|
||||
}
|
||||
|
||||
function withClock(time: number, run: () => void): () => void {
|
||||
return () => {
|
||||
vi.useFakeTimers()
|
||||
@@ -1619,9 +1623,10 @@ describe('ChatView', () => {
|
||||
fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('interrupt now')
|
||||
expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull()
|
||||
expect(turnProcessControl(view.container)?.textContent).toBe('深度求索中,用时2秒')
|
||||
expect(turnProcessControl(view.container)).toBeNull()
|
||||
expect(runningContent(view.container)?.textContent).toMatch(/^深度求索中,用时 \d+秒\.\.\.$/)
|
||||
expect(view.getByRole('status').compareDocumentPosition(view.getByText('interrupt now'))
|
||||
& Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
|
||||
& Node.DOCUMENT_POSITION_PRECEDING).not.toBe(0)
|
||||
|
||||
act(() => {
|
||||
h.setSession({ testInbox: { 'next-turn': [queued], 'next-step': [] } })
|
||||
@@ -2271,7 +2276,8 @@ describe('ChatView', () => {
|
||||
const h = makeHarness({ chat: initial }, { running: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(renderedFlowKinds(view.container)).toEqual(['user', 'turn-process'])
|
||||
expect(turnProcessControl(view.container)?.textContent).toBe('深度求索中,用时4秒')
|
||||
expect(turnProcessControl(view.container)).toBeNull()
|
||||
expect(runningContent(view.container)?.textContent).toMatch(/^深度求索中,用时 \d+秒\.\.\.$/)
|
||||
expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBeNull()
|
||||
|
||||
act(() => {
|
||||
@@ -2453,11 +2459,9 @@ describe('ChatView', () => {
|
||||
turnTimings: new Map([[1, { startTime: 0 }]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const liveToggle = view.getByRole('button', { name: '深度求索中,用时3秒' }) as HTMLButtonElement
|
||||
expect(liveToggle.disabled).toBe(true)
|
||||
expect(liveToggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(turnProcessControl(view.container)).toBeNull()
|
||||
expect(runningContent(view.container)?.textContent).toMatch(/^深度求索中,用时 \d+秒\.\.\.$/)
|
||||
const processRow = view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement
|
||||
fireEvent.click(liveToggle)
|
||||
expect(processRow.getAttribute('hidden')).toBeNull()
|
||||
|
||||
act(() => {
|
||||
@@ -2469,7 +2473,7 @@ describe('ChatView', () => {
|
||||
})
|
||||
})
|
||||
const toggle = turnProcessControl(view.container)!
|
||||
expect(toggle).toBe(liveToggle)
|
||||
expect(runningContent(view.container)).toBeNull()
|
||||
expect(toggle.textContent).toBe('用时 5秒')
|
||||
expect(toggle.disabled).toBe(false)
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
@@ -3203,10 +3207,9 @@ describe('ChatView', () => {
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getByTestId('tool-seat-r1')).toBeTruthy()
|
||||
expect(h.toolOwners[0]?.block).toMatchObject({ callId: 'r1', argsRaw: '{"command":"cmd-r1"}' })
|
||||
expect(view.getByRole('status').textContent).toBe('深度求索中')
|
||||
const toggle = view.getByRole('button', { name: '深度求索中,用时2秒' }) as HTMLButtonElement
|
||||
expect(toggle.disabled).toBe(true)
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(view.getByRole('status').textContent).toBe('深度求索中...')
|
||||
expect(turnProcessControl(view.container)).toBeNull()
|
||||
expect(runningContent(view.container)?.textContent).toMatch(/^深度求索中,用时 \d+秒\.\.\.$/)
|
||||
}))
|
||||
|
||||
it('keeps the Tool renderer mounted when a running call settles into log order', () => {
|
||||
@@ -3265,13 +3268,13 @@ describe('ChatView', () => {
|
||||
)
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const status = view.getByRole('status')
|
||||
const toggle = view.getByRole('button', { name: '深度求索中,用时2分5秒' }) as HTMLButtonElement
|
||||
expect(toggle).toBe(turnProcessControl(view.container))
|
||||
expect(toggle.disabled).toBe(true)
|
||||
expect(status.textContent).toBe('深度求索中')
|
||||
const content = runningContent(view.container)!
|
||||
expect(content.textContent).toMatch(/^深度求索中,用时 \d+分\d+秒\.\.\.$/)
|
||||
expect(turnProcessControl(view.container)).toBeNull()
|
||||
expect(status.textContent).toBe('深度求索中...')
|
||||
expect(status.getAttribute('aria-live')).toBe('polite')
|
||||
expect(status.getAttribute('aria-atomic')).toBe('true')
|
||||
expect(toggle.closest('[aria-live]')).toBeNull()
|
||||
expect(content.closest('[aria-live]')).toBeNull()
|
||||
expect(view.container.querySelector('[data-chat-flow-kind="assistant-step"]')).toBeNull()
|
||||
act(() => {
|
||||
h.setSession({ testInbox: { 'next-turn': [], 'next-step': [{
|
||||
@@ -3281,21 +3284,23 @@ describe('ChatView', () => {
|
||||
role: 'user', source: { kind: 'user' },
|
||||
}] } })
|
||||
})
|
||||
expect(toggle.textContent).toBe('深度求索中,用时2分5秒')
|
||||
expect(content.textContent).toMatch(/^深度求索中,用时 \d+分\d+秒\.\.\.$/)
|
||||
const beforeTick = content.textContent
|
||||
act(() => { vi.advanceTimersByTime(2_000) })
|
||||
expect(toggle.textContent).toBe('深度求索中,用时2分7秒')
|
||||
expect(content.textContent).toMatch(/^深度求索中,用时 \d+分\d+秒\.\.\.$/)
|
||||
expect(content.textContent).not.toBe(beforeTick)
|
||||
expect(view.getByRole('status')).toBe(status)
|
||||
expect(status.textContent).toBe('深度求索中')
|
||||
expect(status.textContent).toBe('深度求索中...')
|
||||
act(() => {
|
||||
h.setSession({ testInbox: { 'next-turn': [], 'next-step': [] } })
|
||||
h.setChat({ nodes: [trigger, { ...steering(2, 'also', 1), time: 128_000 }] })
|
||||
})
|
||||
expect(turnProcessControl(view.container)).toBe(toggle)
|
||||
expect(toggle.textContent).toBe('深度求索中,用时2分7秒')
|
||||
expect(runningContent(view.container)).toBe(content)
|
||||
expect(content.textContent).toMatch(/^深度求索中,用时 \d+分\d+秒\.\.\.$/)
|
||||
expect(view.getByText('also').closest('[data-pending-steering]')).toBeNull()
|
||||
act(() => { vi.advanceTimersByTime(1_000) })
|
||||
expect(toggle.textContent).toBe('深度求索中,用时2分8秒')
|
||||
expect(status.textContent).toBe('深度求索中')
|
||||
expect(content.textContent).toMatch(/^深度求索中,用时 \d+分\d+秒\.\.\.$/)
|
||||
expect(status.textContent).toBe('深度求索中...')
|
||||
view.unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
}))
|
||||
@@ -3308,12 +3313,15 @@ describe('ChatView', () => {
|
||||
{ running: true },
|
||||
)
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const toggle = view.getByRole('button', { name: '深度求索中,用时59分59秒' })
|
||||
const content = runningContent(view.container)!
|
||||
expect(content.textContent).toMatch(/^深度求索中,用时 \d+分\d+秒\.\.\.$/)
|
||||
act(() => { vi.advanceTimersByTime(1_000) })
|
||||
expect(toggle.textContent).toBe('深度求索中,用时1小时00分0秒')
|
||||
expect(content.textContent).toMatch(/^深度求索中,用时 \d+小时\d+分\d+秒\.\.\.$/)
|
||||
const hourlyText = content.textContent
|
||||
act(() => { vi.advanceTimersByTime(303_000) })
|
||||
expect(toggle.textContent).toBe('深度求索中,用时1小时05分3秒')
|
||||
expect(view.getByRole('status').textContent).toBe('深度求索中')
|
||||
expect(content.textContent).toMatch(/^深度求索中,用时 \d+小时\d+分\d+秒\.\.\.$/)
|
||||
expect(content.textContent).not.toBe(hourlyText)
|
||||
expect(view.getByRole('status').textContent).toBe('深度求索中...')
|
||||
view.unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
}))
|
||||
@@ -4089,4 +4097,18 @@ describe('ChatView', () => {
|
||||
expect(failedView.getByText('Compaction cancelled.')).toBeTruthy()
|
||||
expect(failedView.container.querySelector('[data-state="error"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('appends the running status at the transcript bottom only while the Session runs', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'do the thing'), assistant(2, 'working')] }, { running: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const column = view.container.querySelector('[data-chat-flow]')!
|
||||
const status = column.querySelector('[data-chat-running]')
|
||||
expect(column.lastElementChild).toBe(status)
|
||||
expect(within(status as HTMLElement).getByRole('status').textContent).toBe('深度求索中...')
|
||||
expect(status?.lastElementChild?.textContent).toBe('深度求索中...')
|
||||
expect(status?.querySelector('svg')?.parentElement?.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(status?.querySelector('animate')).not.toBeNull()
|
||||
act(() => { h.setSession({ running: false }) })
|
||||
expect(column.querySelector('[data-chat-running]')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { RunningStatus } from '../src/client/chat/RunningStatus.tsx'
|
||||
import { zh } from '../src/client/locale.ts'
|
||||
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
|
||||
beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(5_000) })
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
||||
|
||||
function statusHarness(startTime?: number) {
|
||||
const view = render(<RunningStatus startTime={startTime} t={t} />)
|
||||
return {
|
||||
...view,
|
||||
content: () => view.container.querySelector('[data-chat-running] > :last-child'),
|
||||
set: (nextStartTime?: number) => { view.rerender(<RunningStatus startTime={nextStartTime} t={t} />) },
|
||||
}
|
||||
}
|
||||
|
||||
describe('RunningStatus', () => {
|
||||
it('waits for an open Turn start before allocating its clock', () => {
|
||||
const view = statusHarness()
|
||||
expect(view.content()?.textContent).toBe('深度求索中...')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
view.set(1_000)
|
||||
expect(view.content()?.textContent).toMatch(/^深度求索中,用时 \d+秒\.\.\.$/)
|
||||
expect(view.container.querySelectorAll('[data-text-shimmer]')).toHaveLength(1)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
view.set()
|
||||
expect(view.content()?.textContent).toBe('深度求索中...')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps the indicator mounted while a continuous run moves to the next Turn', () => {
|
||||
const view = statusHarness(1_000)
|
||||
const content = view.content()
|
||||
const status = view.getByRole('status')
|
||||
const initialText = content?.textContent
|
||||
act(() => { vi.advanceTimersByTime(2_000) })
|
||||
expect(content?.textContent).toMatch(/^深度求索中,用时 \d+秒\.\.\.$/)
|
||||
expect(content?.textContent).not.toBe(initialText)
|
||||
view.set(7_000)
|
||||
expect(view.content()).toBe(content)
|
||||
expect(content?.textContent).toMatch(/^深度求索中,用时 \d+秒\.\.\.$/)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
const nextTurnText = content?.textContent
|
||||
act(() => { vi.advanceTimersByTime(2_000) })
|
||||
expect(content?.textContent).toMatch(/^深度求索中,用时 \d+秒\.\.\.$/)
|
||||
expect(content?.textContent).not.toBe(nextTurnText)
|
||||
expect(view.getByRole('status')).toBe(status)
|
||||
expect(status.textContent).toBe('深度求索中...')
|
||||
view.unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps the duration nonnegative when the start is ahead of the local clock', () => {
|
||||
const view = statusHarness(6_000)
|
||||
expect(view.content()?.textContent).toMatch(/^深度求索中,用时 \d+秒\.\.\.$/)
|
||||
expect(view.content()?.textContent).not.toContain('-')
|
||||
act(() => { vi.advanceTimersByTime(3_000) })
|
||||
expect(view.content()?.textContent).toMatch(/^深度求索中,用时 \d+秒\.\.\.$/)
|
||||
expect(view.content()?.textContent).not.toContain('-')
|
||||
})
|
||||
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { RunningWhaleTail } from '../src/client/chat/RunningWhaleTail.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('RunningWhaleTail', () => {
|
||||
it('renders matching animated and resting contours for the stylesheet to switch', () => {
|
||||
const view = render(<RunningWhaleTail />)
|
||||
expect(view.container.firstElementChild?.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(view.container.querySelector('svg')?.getAttribute('viewBox')).toBe('0 0 16 16')
|
||||
const contours = view.container.querySelectorAll('path')
|
||||
expect(contours).toHaveLength(2)
|
||||
expect(contours[1]?.getAttribute('d')).toBe(contours[0]?.getAttribute('d'))
|
||||
const animation = view.container.querySelector('animate')
|
||||
expect(animation?.getAttribute('attributeName')).toBe('d')
|
||||
expect(animation?.getAttribute('begin')).toBe('0.3s')
|
||||
expect(animation?.getAttribute('dur')).toBe('3s')
|
||||
expect(animation?.getAttribute('repeatCount')).toBe('indefinite')
|
||||
const paths = animation!.getAttribute('values')!.split(';')
|
||||
const times = animation!.getAttribute('keyTimes')!.split(';').map(Number)
|
||||
expect(paths.length).toBeGreaterThan(1)
|
||||
expect(paths).toHaveLength(times.length)
|
||||
expect(paths[0]).toBe(view.container.querySelector('path')?.getAttribute('d'))
|
||||
expect(paths.at(-1)).toBe(paths[0])
|
||||
expect(times[0]).toBe(0)
|
||||
expect(times.at(-1)).toBe(1)
|
||||
expect(times.slice(1).every((time, i) => time > times[i]!)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -2,11 +2,12 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { IconClockOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatNode } from '../src/client/contract/chat-nodes.ts'
|
||||
import { TurnTriggerNodeView } from '../src/client/chat/TurnTriggerNodeView.tsx'
|
||||
import { turnTriggerDetails } from '../src/client/chat/turn-trigger.ts'
|
||||
import { contextForm, contextProducer } from '../src/client/conversation-nodes/event-projection.ts'
|
||||
import { en } from '../src/client/locale.ts'
|
||||
import { en, zh } from '../src/client/locale.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -24,7 +25,7 @@ function trigger(source: unknown): ChatNode<'turn-trigger'> {
|
||||
|
||||
describe('Turn trigger notices', () => {
|
||||
it.each([
|
||||
[{ kind: 'schedule' }, 'schedule', 'Scheduled task'],
|
||||
[{ kind: 'schedule' }, 'schedule', 'Automation task'],
|
||||
[{ kind: 'tool-jobs' }, 'job', 'Background task updated'],
|
||||
[{ kind: 'cordis-host-runner' }, 'plugin', 'Plugin status updated'],
|
||||
[{ kind: 'goal' }, 'goal', 'Continuing goal'],
|
||||
@@ -45,6 +46,19 @@ describe('Turn trigger notices', () => {
|
||||
expect(view.container.querySelector('time')?.dateTime).toBe('2023-11-14T22:13:20.000Z')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ locale: en, title: 'Automation task' },
|
||||
{ locale: zh, title: '自动化任务' },
|
||||
])('uses the automation task label $title and shared clock', ({ locale, title }) => {
|
||||
const node = trigger({ kind: 'schedule' })
|
||||
const view = render(<TurnTriggerNodeView node={node} t={makeTranslate(locale)} />)
|
||||
expect(view.getByRole('button').textContent).toContain(title)
|
||||
const reference = render(<IconClockOutlineRegular size={14} />)
|
||||
expect(view.getByRole('button').querySelector('svg')?.outerHTML)
|
||||
.toBe(reference.container.querySelector('svg')?.outerHTML)
|
||||
expect(node.data.source).toEqual({ kind: 'schedule' })
|
||||
})
|
||||
|
||||
it('opens the recorded notice body and closes it independently of the Turn', () => {
|
||||
const view = render(<TurnTriggerNodeView node={trigger({ kind: 'schedule' })} t={makeTranslate(en)} />)
|
||||
const button = view.getByRole('button')
|
||||
|
||||
@@ -154,10 +154,10 @@ export const zh = {
|
||||
'tool.title.searchSessions': '搜索会话',
|
||||
'tool.title.traceSession': '追踪会话',
|
||||
'tool.title.listModels': '查看可用模型',
|
||||
'tool.title.subagent': '创建代理',
|
||||
'tool.title.listAgents': '查看代理',
|
||||
'tool.title.subagent': '创建子智能体',
|
||||
'tool.title.listAgents': '查看子智能体',
|
||||
'tool.title.sendMessage': '发送消息',
|
||||
'tool.title.interruptAgent': '中断代理',
|
||||
'tool.title.interruptAgent': '中断智能体',
|
||||
'tool.title.listJobs': '查看后台任务',
|
||||
'tool.title.readJob': '读取任务输出',
|
||||
'tool.title.killJob': '取消后台任务',
|
||||
@@ -230,14 +230,14 @@ export const zh = {
|
||||
'detail.field.turn': '轮次',
|
||||
'detail.field.step': '步骤',
|
||||
'detail.field.callId': '调用 ID',
|
||||
'detail.field.agents': '启动代理数',
|
||||
'detail.field.agents': '启动智能体数',
|
||||
'detail.field.result': '结果',
|
||||
'detail.field.parent': '父级',
|
||||
'detail.field.depth': '层级',
|
||||
'detail.field.exitCode': '退出码',
|
||||
'detail.field.signal': '信号',
|
||||
'detail.field.previousStatus': '中断前状态',
|
||||
'detail.field.agent': '代理 ID',
|
||||
'detail.field.agent': '智能体 ID',
|
||||
'detail.field.job': '任务 ID',
|
||||
'detail.field.task': '任务内容',
|
||||
'detail.field.processGroup': '进程组',
|
||||
@@ -245,7 +245,7 @@ export const zh = {
|
||||
'detail.field.bestMatch': '最相关事件',
|
||||
'detail.field.target': '目标事件',
|
||||
'detail.field.surface': '记录状态',
|
||||
'detail.agents.count': '{count} 个代理',
|
||||
'detail.agents.count': '{count} 个智能体',
|
||||
'detail.jobs.count': '{count} 个后台任务',
|
||||
'detail.terminals.count': '{count} 个终端',
|
||||
'detail.tasks.count': '{count} 个团队任务',
|
||||
@@ -264,15 +264,15 @@ export const zh = {
|
||||
'detail.wait.title': '子智能体状态',
|
||||
'detail.wait.timeout': '等待超时',
|
||||
'detail.wait.changed': '检测到变化',
|
||||
'detail.agent.reply': '代理回复',
|
||||
'detail.agent.reply': '智能体回复',
|
||||
'detail.models.title': '可用模型',
|
||||
'detail.output.lines': '第 {begin}–{end} 行,共 {total} 行',
|
||||
'detail.output.truncated': '输出已截断',
|
||||
'detail.providers.count': '{count} 个检查提供方',
|
||||
'detail.plugins.count': '{count} 个动态插件',
|
||||
'detail.workflow.script': '工作流脚本',
|
||||
'detail.ralph.reportedComplete': '代理报告完成',
|
||||
'detail.ralph.reportedBlocker': '代理报告受阻',
|
||||
'detail.ralph.reportedComplete': '智能体报告完成',
|
||||
'detail.ralph.reportedBlocker': '智能体报告受阻',
|
||||
'detail.ralph.limit': '已达到轮次上限',
|
||||
'detail.report.nextSteps': '待完成事项',
|
||||
'detail.trace.replacedBy': '被替换为',
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package:
|
||||
en: ca2748d14de42df7
|
||||
zh: 51225f56acb1bada
|
||||
en: 555eccf8bfdd5029
|
||||
zh: e0886cbf6e62bfe3
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle:
|
||||
en: 130b84453fcb0f8c
|
||||
zh: f03976813657b35b
|
||||
en: 915fdd040b4d211c
|
||||
zh: d77e0846f9cbc334
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-a-bundle:
|
||||
en: abc90531efa889d5
|
||||
zh: 0562570f85e69c35
|
||||
|
||||
@@ -31,13 +31,17 @@ Select **Plugins** in the sidebar. The page reads the inventory and the bundles
|
||||
|
||||
Installed bundles and their plugin rows display their own title and description in the current UI language on cards and detail pages. Each field falls back from exported locale `meta` to the accessible `package.json` at that plugin address; the final title is the full package or module name, with no package description when both sources omit it. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. Bundle cards, details, and component rows display the image declared by their own `package.json.icon`; absent or undecodable images retain the default artwork. Installation previews still use registry or manifest information.
|
||||
|
||||
The first list read shows a short Official-heading placeholder and four skeleton cards, using the real cards' row, icon, and text-line spacing, with the action area left blank. They use the same opacity pulse as Session search and remain static with reduced motion. The accessible loading label and read timing are unchanged; refreshes retain the existing list, and installation and component-loading indicators keep their own states.
|
||||
|
||||
The **Refresh** button shows its tooltip after 500 ms of hovering or keyboard focus. During a manual refresh, the button shows a spinner and stays disabled until all pending reads settle and at least 400 ms have elapsed, preventing a brief spinner flash; repeated refresh requests are ignored and the existing list remains visible. Success shows no toast. With a loaded list, a failed manual refresh keeps the cached content and shows a failure toast, including after leaving Plugins; select **Refresh** again to retry. Before any successful list read, failures show an in-place message with **Retry**. Background reads do not show the refresh spinner.
|
||||
|
||||
### Installing a bundle
|
||||
|
||||
On first use, when pnpm uses the unconfigured official npm registry and npmmirror is offered, the Host probes both registries in parallel. The first successful HTTPS ping response selects the initial source. The dialog preserves remembered choices, manual selections, explicit manager configuration, and custom or unknown pnpm registries. An install clicked during initial probing waits for that bounded operation; a late result cannot overwrite a manual choice or reopen a closed dialog.
|
||||
|
||||
The Host sends GET requests to `https://registry.npmjs.org/-/ping` and `https://registry.npmmirror.com/-/ping` through its normal fetch proxy. A 2xx response wins; redirects and failures do not. The remaining request is cancelled, both response bodies are discarded, and cleanup completes before the result returns. The default deadline is 1500 ms and results, including unavailable results, are cached for five minutes. Configure `registryProbeTimeoutMs` and `registryProbeCacheTtlMs` on `ui-plugin-manager`; `registryProbeEnabled: false` disables probing. If both requests fail or time out, the existing default remains selected. No IP-location service or Session content is involved.
|
||||
|
||||
**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. **Install guide and examples** under the field opens a guide that shows the three common forms with an example each; **Use example** drops one into the field. **Registry** beside it names the registry the install asks first and unfolds the choice: pnpm's own registry, named by the registry it actually names — the official npm registry, the mainland China mirror, or its host — which keeps the neutral default name until the Host reads it, is listed once, and carries its host unless that host is already its name; each mirror the Host configured (`pluginManager.registries`), npmmirror as the mainland China mirror; and a typed http(s) address. The options float over the dialog from the control, so unfolding them never lengthens the card, and the card's content scrolls when the guide makes it taller than the viewport. The initial choice follows the response comparison above; the choice is then remembered in this browser (`localStorage`) and starts the next dialog, and a remembered address the Host no longer offers stays as a typed one and remains the requested address when installation starts before the registry list returns. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`) at the chosen registry: a name the list already shows, a name no registry has, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing; when no registry could be reached, the sentence names every one asked. The install then starts at the registry that answered. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. While the Host moves on to another registry, the installing screen says which registry could not serve the package and which is asked now, and each pnpm run behind the details carries a badge naming its registry. A failed install says what went wrong in one line, as the Host attributed it — no registry could be reached, naming every one asked; the host a GitHub address or a tarball link is fetched from could not be reached, which no registry helps; the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand, and **Change registry** beside it when the Host laid the failure at the registry it asked, which returns to the spec with the registry options open; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate.
|
||||
**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. Enter in the package or custom-registry field submits the install request, but Enter used to confirm an input-method candidate does not. **Install guide and examples** under the field opens a guide that shows the three common forms with an example each; **Use example** drops one into the field. **Registry** beside it names the registry the install asks first and unfolds the choice: pnpm's own registry, named by the registry it actually names — the official npm registry, the mainland China mirror, or its host — which keeps the neutral default name until the Host reads it, is listed once, and carries its host unless that host is already its name; each mirror the Host configured (`pluginManager.registries`), npmmirror as the mainland China mirror; and a typed http(s) address. The options use an opaque selector panel, fill rows only on hover, and show hosts in tertiary text without parentheses. The custom-address area highlights on hover; clicking anywhere in it selects the custom registry and focuses its input, whose focus style matches the package field. Keyboard focus alone preserves the selected registry; activating the custom radio or editing the address selects the custom registry. Tab moves from the selected radio to the address, and Shift+Tab moves back; leaving either end closes the selector and returns focus to **Registry**. Switching to an offered registry retains the custom URL draft for switching back. Submitting an invalid custom address blocks installation, opens the registry selector, and focuses the address. Invalid package and custom-address fields keep a red error border and add the shared blue inset focus ring on focus. The options float over the dialog from the control, so unfolding them never lengthens the card, and the card's content scrolls when the guide makes it taller than the viewport. The initial choice follows the response comparison above; the choice is then remembered in this browser (`localStorage`) and starts the next dialog, and a remembered address the Host no longer offers stays as a typed one and remains the requested address when installation starts before the registry list returns. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`) at the chosen registry: a name the list already shows, a name no registry has, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing; when no registry could be reached, the sentence names every one asked. The install then starts at the registry that answered. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. While the Host moves on to another registry, the installing screen says which registry could not serve the package and which is asked now, and each pnpm run behind the details carries a badge naming its registry. A failed install says what went wrong in one line, as the Host attributed it — no registry could be reached, naming every one asked; the host a GitHub address or a tarball link is fetched from could not be reached, which no registry helps; the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand, and **Change registry** beside it when the Host laid the failure at the registry it asked, which returns to the spec with the registry options open; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate.
|
||||
|
||||
During preparation and download, **Cancel install** asks the Host to stop the run and waits for confirmation. Loading the bundle cannot be cancelled. Closing with ×, Escape, or the backdrop hides the dialog immediately and requests cancellation when possible. **View installation** reopens the same task with its output; another installation cannot start while its outcome is pending or unconfirmed. Confirmed cancellation returns to the spec and shows a toast; the manifest and lockfile are restored, while downloaded files can remain. A lost installation response triggers a request to recover the result; **Check installation status** and reconnect retry that request. If the Host has no active request, **Installation result unavailable** allows returning to editing after checking the plugin list. A cancellation sent before acceptance waits and retries automatically; a failed cancellation can be retried manually. Hidden tasks report their outcome through toasts without reopening the dialog.
|
||||
|
||||
|
||||
@@ -31,13 +31,17 @@ kind: "package-reference"
|
||||
|
||||
已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到该插件地址下可访问的 `package.json`;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。组合包卡片、详情和组件行显示各自 `package.json.icon` 声明的图片;未声明或无法解码时保留默认插画。安装预览仍使用注册表或 manifest 信息。
|
||||
|
||||
首次读取列表时,页面显示一条较短的「官方」标题占位和四行骨架卡片,沿用真实卡片的行、图标和文字间距,操作区域留白。骨架使用与 Session 搜索相同的透明度呼吸动效,在减少动态效果模式下保持静止。无障碍加载文案和读取时机不变;刷新时保留已有列表,安装及组件加载仍使用各自的状态提示。
|
||||
|
||||
**刷新**按钮在悬停或键盘聚焦 500 毫秒后显示提示。手动刷新期间,按钮显示旋转加载指示并保持禁用,直到所有待处理读取结束且至少经过 400 毫秒,避免加载指示一闪而过;重复刷新请求会被忽略,已有列表保持可见。成功不显示 toast。已有列表时,手动刷新失败保留缓存内容并显示失败 toast,即使已离开插件页也能看到;再次选择**刷新**即可重试。在首次成功读取列表之前,失败仍原位显示提示与**重试**。后台读取不显示刷新按钮的加载指示。
|
||||
|
||||
### 安装一个组合包
|
||||
|
||||
首次使用时,如果未显式配置安装源、pnpm 使用官方 npm 源,且列表提供 npmmirror,Host 会并发探测这两个源,选择最先成功响应 HTTPS ping 的源。已记住的选择、用户手选、管理器显式配置,以及自定义或未知的 pnpm 源均保留。在初次探测期间点击安装会等待这次有时限的操作;迟到结果不会覆盖手选或重新打开已关闭的对话框。
|
||||
|
||||
Host 通过普通 fetch 代理向 `https://registry.npmjs.org/-/ping` 和 `https://registry.npmmirror.com/-/ping` 发送 GET 请求,以最先返回 2xx 的源为结果;重定向和失败不参与选择。随后取消另一条请求、丢弃两个响应体,并在清理完成后返回。默认时限为 1500 毫秒,包括不可用结果在内的探测结果均缓存五分钟。在 `ui-plugin-manager` 上配置 `registryProbeTimeoutMs` 和 `registryProbeCacheTtlMs`;`registryProbeEnabled: false` 关闭探测。两条请求都失败或超时时保留现有默认源。不使用 IP 地区服务,也不发送 Session 内容。
|
||||
|
||||
**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。输入框下方的**插件安装引导和示例**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。旁边的**安装源**写着安装首先询问的注册表,展开后可选:pnpm 自身的注册表,按它实际指向的源命名——npm 官方源、中国大陆镜像源,其余以主机名显示——Host 读不到时保留中性的默认名称,只列一次,选项标题带上主机名(主机名即名称时不重复显示);Host 配置的每个镜像(`pluginManager.registries`),npmmirror 显示为中国大陆镜像源;以及手动输入的 http(s) 地址。选项从控件上浮在对话框之上,展开不会拉长卡片;引导使卡片高过视口时,卡片内容可以滚动。初始选择遵循上文的响应比较规则;之后选择记在本浏览器(`localStorage`)里,下次打开对话框从它开始,Host 不再提供的已记住地址保留为手动输入的地址;在安装源列表返回前开始安装,也仍请求该地址。**安装**先让 Host 在所选注册表读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、所有注册表都没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑;所有注册表都连不上时,这句话列出问过的每一个。随后安装从作答的那个注册表开始。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。Host 改问另一个注册表时,安装中界面会说明哪个源没能提供这个包、现在改问哪个,详情里每次 pnpm 运行都带一个写明所用源的标记。安装失败时按 Host 的归因用一行话说明原因——所有安装源都连不上,并列出问过的每一个;GitHub 地址或压缩包链接自身的主机连不上,换源无济于事;包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边,Host 把失败归于所问注册表时还在旁边提供**更换安装源**,回到 spec 输入界面并展开安装源选项;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。
|
||||
**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。在包名或自定义安装源输入框中按 Enter 会提交安装请求,但用于确认输入法候选词的 Enter 不会触发安装。输入框下方的**插件安装引导和示例**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。旁边的**安装源**写着安装首先询问的注册表,展开后可选:pnpm 自身的注册表,按它实际指向的源命名——npm 官方源、中国大陆镜像源,其余以主机名显示——Host 读不到时保留中性的默认名称,只列一次,选项标题带上主机名(主机名即名称时不重复显示);Host 配置的每个镜像(`pluginManager.registries`),npmmirror 显示为中国大陆镜像源;以及手动输入的 http(s) 地址。选项使用不透明选择面板,仅悬停行使用底色,主机名不加括号并使用三级文字色。自定义地址区域在悬停时高亮;点击其中任意位置会选中自定义源并聚焦输入框,焦点样式与包名输入框一致。仅通过键盘聚焦不会改变已选安装源;激活自定义单选项或编辑地址才会选中自定义源。Tab 从已选单选项移到地址框,Shift+Tab 反向返回;从任一端移出时关闭选择器,并将焦点交还**安装源**按钮。切到预设源时保留自定义地址草稿,切回后继续使用。提交无效的自定义地址时会阻止安装,展开安装源选择器并聚焦地址框。包名和自定义地址输入框无效时保留红色错误边框,聚焦时加上共享的蓝色内嵌焦点环。选项从控件上浮在对话框之上,展开不会拉长卡片;引导使卡片高过视口时,卡片内容可以滚动。初始选择遵循上文的响应比较规则;之后选择记在本浏览器(`localStorage`)里,下次打开对话框从它开始,Host 不再提供的已记住地址保留为手动输入的地址;在安装源列表返回前开始安装,也仍请求该地址。**安装**先让 Host 在所选注册表读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、所有注册表都没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑;所有注册表都连不上时,这句话列出问过的每一个。随后安装从作答的那个注册表开始。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。Host 改问另一个注册表时,安装中界面会说明哪个源没能提供这个包、现在改问哪个,详情里每次 pnpm 运行都带一个写明所用源的标记。安装失败时按 Host 的归因用一行话说明原因——所有安装源都连不上,并列出问过的每一个;GitHub 地址或压缩包链接自身的主机连不上,换源无济于事;包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边,Host 把失败归于所问注册表时还在旁边提供**更换安装源**,回到 spec 输入界面并展开安装源选项;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。
|
||||
|
||||
准备和下载期间,**取消安装**会请求 Host 停止运行并等待确认。加载组合包的阶段不可取消。点击 ×、按 Escape 或点击遮罩会立即隐藏对话框,并在可以取消时请求取消。**查看安装任务**会重新打开同一任务并保留输出;结果待定或尚未确认时不能发起另一项安装。确认取消后回到 spec 输入界面并显示 toast;manifest 与 lockfile 已恢复,已下载文件可能保留。安装响应丢失后会请求恢复结果;**核对安装状态**和重连会重试该请求。Host 已无活动请求时,**未能获取安装结果**允许检查插件列表后返回编辑。早于接收确认的取消请求会等待并自动重试;取消失败可手动重试。隐藏的任务通过 toast 通知结果,不会重新弹出对话框。
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
@keyframes dsh-plugin-highlight {
|
||||
0%,
|
||||
55% {
|
||||
box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 40%, transparent);
|
||||
}
|
||||
|
||||
100% {
|
||||
@@ -157,7 +157,7 @@
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card[data-plugin-highlight] {
|
||||
animation: none;
|
||||
box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 40%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,7 +271,7 @@
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
border-radius: var(--dsw-radius-xl);
|
||||
}
|
||||
|
||||
.cardOpen:focus-visible {
|
||||
@@ -293,6 +293,46 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Match Session search's pulse without coupling the two feature packages. */
|
||||
.skeletonFill {
|
||||
background: var(--dsw-alias-bg-skeleton);
|
||||
animation: dsh-plugin-skeleton 2s cubic-bezier(0.36, 0, 0.64, 1) infinite;
|
||||
}
|
||||
|
||||
/* Keep each real text line's height; center the shorter painted bar within it. */
|
||||
.skeletonText {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 1lh;
|
||||
}
|
||||
|
||||
.skeletonBar {
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
border-radius: var(--dsw-radius-xs);
|
||||
}
|
||||
|
||||
.skeletonHeading { width: 48px; }
|
||||
.skeletonTitle { width: min(144px, 60%); }
|
||||
.skeletonDescription { width: min(280px, 85%); }
|
||||
.skeletonIcon { border-color: transparent; }
|
||||
|
||||
/* Keep text-column metrics while leaving the action area blank. */
|
||||
.skeletonActions {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
@keyframes dsh-plugin-skeleton {
|
||||
0% { opacity: 1; }
|
||||
40% { opacity: 0.6; }
|
||||
80%, 100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeletonFill { animation: none; }
|
||||
}
|
||||
|
||||
.cardEnd {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -462,13 +502,21 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.installField input[type='text']:focus {
|
||||
.installField input[type='text']:focus,
|
||||
.registryCustomField:focus {
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
box-shadow: inset 0 0 0 0.5px var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.installField input[aria-invalid='true'] {
|
||||
.installField input[type='text'][aria-invalid='true'],
|
||||
.registryCustomField[aria-invalid='true'] {
|
||||
border-color: var(--dsw-alias-state-error-primary);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.installField input[type='text'][aria-invalid='true']:focus,
|
||||
.registryCustomField[aria-invalid='true']:focus {
|
||||
box-shadow: inset 0 0 0 0.5px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
}
|
||||
|
||||
/* The guide under the field: opened by a text button, it says what an ID is and shows the three forms with examples. */
|
||||
@@ -610,10 +658,10 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 26px;
|
||||
height: 28px;
|
||||
padding: 0 8px 0 10px;
|
||||
border: 0.5px solid var(--dsw-alias-border-l4);
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
border-radius: var(--dsw-radius-md);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
@@ -655,12 +703,12 @@
|
||||
box-sizing: border-box;
|
||||
width: min(440px, calc(100vw - 24px));
|
||||
margin: 0;
|
||||
padding: 8px 10px 12px;
|
||||
padding: 8px;
|
||||
border: 0;
|
||||
border-radius: var(--dsw-radius-lg);
|
||||
/* An opaque layer keeps the registry form controls readable over the dialog. */
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
/* The neutral stroke is the elevation's hairline, not a border beside it. */
|
||||
--dsw-elevation-stroke-color: var(--dsw-alias-border-l3);
|
||||
border-radius: var(--dsw-radius-lg);
|
||||
--dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
|
||||
box-shadow: var(--dsw-elevation-prominent);
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -670,14 +718,13 @@
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 10px;
|
||||
border: 0.5px solid transparent;
|
||||
border-radius: var(--dsw-radius-lg);
|
||||
border: 0;
|
||||
border-radius: var(--dsw-radius-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.registryOption[data-checked='true'] {
|
||||
border-color: color-mix(in srgb, var(--dsw-alias-brand-primary) 40%, transparent);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
.registryOption:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* An offered registry is one row: the radio beside its title and the line under it. */
|
||||
@@ -720,6 +767,7 @@ label.registryOption {
|
||||
}
|
||||
|
||||
.registryCustomField {
|
||||
outline: none;
|
||||
height: 32px;
|
||||
margin-left: 26px;
|
||||
padding: 0 10px;
|
||||
@@ -731,10 +779,6 @@ label.registryOption {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.registryCustomField[aria-invalid='true'] {
|
||||
border-color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* The typed address's field, refusal, and line sit under its title, past the radio and its gap. */
|
||||
.registryOption > .inputError,
|
||||
.registryOption > .registryHint {
|
||||
|
||||
@@ -16,9 +16,9 @@ import {
|
||||
Button, IconCheckCircleFillRegular, IconChevronDownOutlineRegular, IconChevronLeftOutlineMedium,
|
||||
IconChevronRightOutlineRegular, IconCloseOutlineMedium,
|
||||
IconPlusOutlineRegular, IconRefreshOutlineRegular, IconTrashOutlineRegular,
|
||||
IconWarningOutlineRegular, Input, Modal,
|
||||
IconWarningOutlineRegular, Input, Modal, pointerModality,
|
||||
PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTerminal,
|
||||
StateDot, Switch, Tag, TerminalBlock, Toast, useAnchoredPosition, useDismissOnOutsidePointer,
|
||||
StateDot, Switch, Tag, TerminalBlock, Toast, Tooltip, useAnchoredPosition, useDismissOnOutsidePointer,
|
||||
type IconProps, type StateDotState, type TerminalBlockLabels,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -323,6 +323,39 @@ function CardHead({ title, t, onOpen, icon, tags, description, end }: {
|
||||
)
|
||||
}
|
||||
|
||||
/** First-read placeholders share the Official group's card and text-line layout. */
|
||||
function ListSkeleton({ label }: { readonly label: string }): ReactNode {
|
||||
return (
|
||||
<section className={css.group} role="status" aria-label={label} data-plugin-loading>
|
||||
<div className={css.groupHead} aria-hidden="true">
|
||||
<span className={`${css.groupTitle} ${css.skeletonText} ${css.skeletonHeading}`}>
|
||||
<span className={`${css.skeletonFill} ${css.skeletonBar}`} />
|
||||
</span>
|
||||
</div>
|
||||
<ul className={css.cards} aria-hidden="true">
|
||||
{[0, 1, 2, 3].map(index => (
|
||||
<li key={index} className={css.card}>
|
||||
<div className={css.cardHead}>
|
||||
<span className={`${css.cardIcon} ${css.skeletonFill} ${css.skeletonIcon}`} />
|
||||
<div className={css.cardMain}>
|
||||
<div className={css.titleRow}>
|
||||
<span className={`${css.cardTitle} ${css.skeletonText} ${css.skeletonTitle}`}>
|
||||
<span className={`${css.skeletonFill} ${css.skeletonBar}`} />
|
||||
</span>
|
||||
</div>
|
||||
<span className={`${css.cardDesc} ${css.skeletonText} ${css.skeletonDescription}`}>
|
||||
<span className={`${css.skeletonFill} ${css.skeletonBar}`} />
|
||||
</span>
|
||||
</div>
|
||||
<div className={`${css.cardEnd} ${css.skeletonActions}`} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** The top every page shares: the crumb that leads back, then the icon with the page's actions at its right. */
|
||||
function DetailTop({ crumbLabel, crumbText, onBack, icon, actions }: {
|
||||
readonly crumbLabel: string
|
||||
@@ -681,10 +714,10 @@ function registryList(registries: readonly Registry[], t: Translate, resolved: s
|
||||
return registries.map(registry => registryText(registry, t, resolved).name).join(t('registryListSeparator'))
|
||||
}
|
||||
|
||||
/** An option's label: the registry's name with the host it names, unless the host is the name. */
|
||||
function registryOption(registry: Registry, t: Translate, resolved: string | null): string {
|
||||
/** An option's name with its host in tertiary text, unless the host is the name. */
|
||||
function registryOption(registry: Registry, t: Translate, resolved: string | null): ReactNode {
|
||||
const { name, host } = registryText(registry, t, resolved)
|
||||
return name === host ? name : t('registryWithHost', { name, host })
|
||||
return name === host ? name : <>{name}{' '}<span className={css.registryHint}>{host}</span></>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -726,6 +759,19 @@ function SubjectCard({ subject, t }: { readonly subject: InstallSubject; readonl
|
||||
)
|
||||
}
|
||||
|
||||
/** Track one install field's composition, including Safari's 10ms post-composition Enter window. */
|
||||
function useInstallComposition(active: boolean) {
|
||||
const composition = useRef({ active: false, until: 0 })
|
||||
useEffect(() => { composition.current = { active: false, until: 0 } }, [active])
|
||||
return {
|
||||
onCompositionStart: () => { composition.current.active = true },
|
||||
onCompositionEnd: () => { composition.current = { active: false, until: Date.now() + 10 } },
|
||||
onBlur: () => { composition.current = { active: false, until: 0 } },
|
||||
isComposing: (event: KeyboardEvent) => event.isComposing || Reflect.get(event, 'keyCode') === 229
|
||||
|| composition.current.active || Date.now() < composition.current.until,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The install dialog: the spec and its check, then the installing, installed,
|
||||
* and failed screens over the same subject card. A failed run that left
|
||||
@@ -757,15 +803,23 @@ function InstallDialog({
|
||||
const registryId = useId()
|
||||
const registryErrorId = useId()
|
||||
const [guideOpen, setGuideOpen] = useState(false)
|
||||
const [customRegistryDraft, setCustomRegistryDraft] = useState('')
|
||||
const { phase } = install
|
||||
// The registry options float over the dialog from their toggle, so unfolding them never adds to its height;
|
||||
// the store folds them when a run starts, so they show at the spec only.
|
||||
const registryToggleRef = useRef<HTMLButtonElement | null>(null)
|
||||
const registryPanelRef = useRef<HTMLFieldSetElement | null>(null)
|
||||
const registryCustomRef = useRef<HTMLInputElement | null>(null)
|
||||
const registryShown = install.registryOpen && phase === 'idle'
|
||||
const specComposition = useInstallComposition(install.open && phase === 'idle')
|
||||
const registryComposition = useInstallComposition(install.open && registryShown)
|
||||
const registryPosition = useAnchoredPosition({
|
||||
open: registryShown, anchorRef: registryToggleRef, panelRef: registryPanelRef, align: 'end', gap: 6, margin: 12,
|
||||
})
|
||||
const registryReady = registryShown && registryPosition !== null
|
||||
useEffect(() => {
|
||||
if (registryReady && install.registryError) registryCustomRef.current?.focus()
|
||||
}, [registryReady, install.registryError])
|
||||
// The hook only ever asks to close.
|
||||
useDismissOnOutsidePointer(registryToggleRef, registryShown, onToggleRegistry, registryPanelRef)
|
||||
useEffect(() => {
|
||||
@@ -849,7 +903,13 @@ function InstallDialog({
|
||||
aria-invalid={install.inputError !== null}
|
||||
aria-describedby={install.inputError === null ? undefined : errorId}
|
||||
onChange={(event) => { onEditSpec(event.currentTarget.value) }}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter' && !empty && !checking) onRun() }}
|
||||
onCompositionStart={specComposition.onCompositionStart}
|
||||
onCompositionEnd={specComposition.onCompositionEnd}
|
||||
onBlur={specComposition.onBlur}
|
||||
onKeyDown={(event) => {
|
||||
if (specComposition.isComposing(event.nativeEvent)) return
|
||||
if (event.key === 'Enter' && !empty && !checking) onRun()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{inputSentence === null
|
||||
@@ -925,37 +985,70 @@ function InstallDialog({
|
||||
style={registryPosition ?? { visibility: 'hidden', left: 0, top: 0 }}
|
||||
data-install-registry
|
||||
aria-label={t('registryLegend')}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Tab' || event.ctrlKey || event.altKey || event.metaKey || event.nativeEvent.isComposing) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const radio = event.currentTarget.querySelector<HTMLInputElement>('input[type="radio"]:checked')
|
||||
const field = registryCustomRef.current
|
||||
if (event.shiftKey && event.target === field) radio?.focus()
|
||||
else if (!event.shiftKey && event.target !== field) field?.focus()
|
||||
else {
|
||||
onToggleRegistry()
|
||||
registryToggleRef.current?.focus()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{offeredRegistries(install.registries).map((registry) => {
|
||||
const checked = choice.kind === 'offered' && choice.registry === registry
|
||||
return (
|
||||
<label key={registry ?? ''} className={css.registryOption} data-checked={checked}>
|
||||
<input type="radio" name={registryId} checked={checked} onChange={() => { onChooseRegistry({ kind: 'offered', registry }) }} />
|
||||
<span className={css.registryTitle}><span>{registryOption(registry, t, resolved)}</span></span>
|
||||
<input type="radio" name={registryId} checked={checked} onChange={() => {
|
||||
if (choice.kind === 'custom') setCustomRegistryDraft(choice.url)
|
||||
onChooseRegistry({ kind: 'offered', registry })
|
||||
}} />
|
||||
<span className={css.registryTitle}>{registryOption(registry, t, resolved)}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
<div className={css.registryOption} data-checked={choice.kind === 'custom'}>
|
||||
<div className={css.registryOption} data-checked={choice.kind === 'custom'}
|
||||
onClick={(event) => {
|
||||
// Keep label activation from moving focus back to the radio.
|
||||
if (!(event.target instanceof HTMLInputElement)) event.preventDefault()
|
||||
registryCustomRef.current?.focus()
|
||||
}}>
|
||||
<label className={css.registryCustomPick}>
|
||||
<input
|
||||
type="radio"
|
||||
name={registryId}
|
||||
checked={choice.kind === 'custom'}
|
||||
onChange={() => { onChooseRegistry({ kind: 'custom', url: '' }) }}
|
||||
onChange={() => {
|
||||
onChooseRegistry({ kind: 'custom', url: customRegistryDraft })
|
||||
registryCustomRef.current?.focus()
|
||||
}}
|
||||
/>
|
||||
<span className={css.registryTitle}><span>{t('registryCustom')}</span></span>
|
||||
</label>
|
||||
<input
|
||||
ref={registryCustomRef}
|
||||
type="text"
|
||||
className={css.registryCustomField}
|
||||
aria-label={t('registryCustom')}
|
||||
placeholder={t('registryCustomPlaceholder')}
|
||||
value={choice.kind === 'custom' ? choice.url : ''}
|
||||
disabled={choice.kind !== 'custom'}
|
||||
value={choice.kind === 'custom' ? choice.url : customRegistryDraft}
|
||||
aria-invalid={install.registryError}
|
||||
aria-describedby={install.registryError ? registryErrorId : undefined}
|
||||
onFocus={() => {
|
||||
if (pointerModality() && choice.kind !== 'custom') onChooseRegistry({ kind: 'custom', url: customRegistryDraft })
|
||||
}}
|
||||
onChange={(event) => { onChooseRegistry({ kind: 'custom', url: event.currentTarget.value }) }}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter' && !empty) onRun() }}
|
||||
onCompositionStart={registryComposition.onCompositionStart}
|
||||
onCompositionEnd={registryComposition.onCompositionEnd}
|
||||
onBlur={registryComposition.onBlur}
|
||||
onKeyDown={(event) => {
|
||||
if (registryComposition.isComposing(event.nativeEvent)) return
|
||||
if (event.key === 'Enter' && !empty) onRun()
|
||||
}}
|
||||
/>
|
||||
{install.registryError
|
||||
? <p id={registryErrorId} className={css.inputError} role="alert">{t('registryCustomInvalid')}</p>
|
||||
@@ -1168,7 +1261,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
const timer = setTimeout(clearHighlight, HIGHLIGHT_MS)
|
||||
return () => { clearTimeout(timer) }
|
||||
}, [highlight, clearHighlight])
|
||||
const noticeLine = state.notice === null ? null : noticeText(state.notice, t)
|
||||
const noticeLine = state.notice === null || state.notice.kind === 'refresh-failed' ? null : noticeText(state.notice, t)
|
||||
|
||||
// The page manages what the person installed, what the installation ships for them to switch on, and a
|
||||
// selected name the Host cannot read; the installation's other bundles are inspected in the Settings
|
||||
@@ -1178,6 +1271,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
const mine = listed.filter(pkg => pkg.installed || !pkg.optional)
|
||||
const official = listed.filter(pkg => pkg.optional && !pkg.installed)
|
||||
const loaded = state.status === 'ready' || state.status === 'error'
|
||||
const refreshing = state.refreshStatus === 'refreshing'
|
||||
const openPkg = view.kind === 'package' || view.kind === 'row' ? listed.find(pkg => pkg.name === view.name) : undefined
|
||||
const openItem = view.kind === 'item' ? ledger.items.find(item => item.id === view.id) : undefined
|
||||
const openRow = view.kind === 'row' && openPkg !== undefined ? openPkg.rows.find(row => row.rowId === view.rowId) : undefined
|
||||
@@ -1224,7 +1318,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
)
|
||||
|
||||
return (
|
||||
<section className={css.page} data-plugin-panel aria-busy={state.status === 'loading'}>
|
||||
<section className={css.page} data-plugin-panel aria-busy={state.status === 'loading' || refreshing}>
|
||||
{showsCards
|
||||
? (
|
||||
<header className={css.pageHead} data-window-drag>
|
||||
@@ -1233,9 +1327,13 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
<p className={css.pageIntro}>{t('intro')}</p>
|
||||
</div>
|
||||
<div className={css.toolbar}>
|
||||
<button type="button" className={css.iconButton} aria-label={t('refresh')} title={t('refresh')} disabled={!loaded} onClick={props.refresh}>
|
||||
<span className={css.iconWrap} aria-hidden="true"><IconRefreshOutlineRegular /></span>
|
||||
</button>
|
||||
<Tooltip label={t('refresh')} delayMs={500} focusDelayMs={500} side="bottom" portal disabled={!loaded || refreshing}>
|
||||
<button type="button" className={css.iconButton} aria-label={t('refresh')} aria-busy={refreshing} disabled={!loaded || refreshing} onClick={props.refresh}>
|
||||
<span className={css.iconWrap} aria-hidden="true">
|
||||
{refreshing ? <StateDot state="ongoing" size={18} /> : <IconRefreshOutlineRegular />}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Button variant="primary" size="sm" className={css.addButton} icon={<IconPlusOutlineRegular size={13} />} disabled={!loaded} onClick={props.openInstall}>
|
||||
{t(state.install.requestId === undefined ? 'addPlugin' : 'installViewTask')}
|
||||
</Button>
|
||||
@@ -1243,11 +1341,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
</header>
|
||||
)
|
||||
: null}
|
||||
{showsCards && state.status === 'loading' ? (
|
||||
<p className={`${css.status} ${css.statusWithDot}`} role="status">
|
||||
<StateDot state="ongoing" />{t('loading')}
|
||||
</p>
|
||||
) : null}
|
||||
{showsCards && state.status === 'loading' ? <ListSkeleton label={t('loading')} /> : null}
|
||||
{showsCards && state.status === 'unavailable' ? (
|
||||
<p className={`${css.status} ${css.statusWithDot}`} role="status">
|
||||
<StateDot state="idle" />{t('unavailable')}
|
||||
@@ -1264,11 +1358,11 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
onDone={props.dismissNotice}
|
||||
/>
|
||||
)}
|
||||
{!showsCards && state.status === 'error'
|
||||
{!showsCards && state.status === 'error' && !refreshing
|
||||
? (
|
||||
<div className={css.failure}>
|
||||
<p className={css.statusWithDot} role="alert">
|
||||
<StateDot state="error" />{t('error')}
|
||||
<StateDot state="error" />{t(state.refreshStatus === 'failed' ? 'refreshError' : 'error')}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={props.refresh}>{t('retry')}</Button>
|
||||
</div>
|
||||
@@ -1317,11 +1411,11 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
{renderGroup('bundles', t('bundlesTitle'), mine.map(packageCard))}
|
||||
{/* A failed package read trails the groups it left incomplete: right under Official on a
|
||||
first-load failure, and after the kept cards when a refresh fails over stale data. */}
|
||||
{state.status === 'error'
|
||||
{state.status === 'error' && !refreshing
|
||||
? (
|
||||
<div className={css.failure}>
|
||||
<p className={css.statusWithDot} role="alert">
|
||||
<StateDot state="error" />{t('error')}
|
||||
<StateDot state="error" />{t(state.refreshStatus === 'failed' ? 'refreshError' : 'error')}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={props.refresh}>{t('retry')}</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/** Refresh failure feedback hosted outside the Plugins panel's lifetime. */
|
||||
import type { ReactNode } from 'react'
|
||||
import { IconWarningOutlineRegular, Toast } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { PluginManagerFace } from './manager-store.ts'
|
||||
import { noticeText } from './presentation.ts'
|
||||
|
||||
/** The controller's shared notice source and dismissal action. */
|
||||
export type PluginRefreshToastFace = Pick<PluginManagerFace, 'dismissNotice'> & {
|
||||
hooks: Pick<PluginManagerFace['hooks'], 'pluginManager'>
|
||||
}
|
||||
|
||||
/** Render inputs bound from the shared controller and plugin dictionary. */
|
||||
export type PluginRefreshToastProps = InjectFace<PluginRefreshToastFace> & PropsLocale<'pluginManager'>
|
||||
|
||||
/**
|
||||
* Display refresh failures even after navigation leaves the Plugins panel.
|
||||
* @param props - shared notice hook, dismissal action, and locale seat.
|
||||
* @returns the refresh failure toast, or null for other notices.
|
||||
*/
|
||||
export function PluginRefreshToast({ usePluginManager, dismissNotice, t }: PluginRefreshToastProps): ReactNode {
|
||||
const notice = usePluginManager(state => state.notice)
|
||||
if (notice?.kind !== 'refresh-failed') return null
|
||||
return (
|
||||
<Toast
|
||||
key={notice.seq}
|
||||
text={noticeText(notice, t)}
|
||||
icon={<IconWarningOutlineRegular />}
|
||||
onDone={dismissNotice}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
// through the owning package's client-safe types subpath).
|
||||
import type {} from '@deepseek-ai/dsh-plugin-manager/types'
|
||||
import { PluginManagerPage } from './PluginManagerPage.tsx'
|
||||
import { PluginRefreshToast, type PluginRefreshToastFace } from './PluginRefreshToast.tsx'
|
||||
import { PluginsPanelIcon } from './PluginsPanelIcon.tsx'
|
||||
import { configLedgerSource } from './config-ledger.ts'
|
||||
import { PluginManagerController } from './manager-store.ts'
|
||||
@@ -97,6 +98,14 @@ export function apply(ctx: ClientContext): void {
|
||||
// the page's own; a plugin's configuration arrives through the slots the
|
||||
// page declares here, so the page never names a configurable plugin.
|
||||
const configLedger = configLedgerSource(ctx)
|
||||
const face = controller.inject(configLedger, text => ctx.locale.resolveText(text))
|
||||
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
||||
name: 'shell.overlay', id: 'plugin-manager.refresh-toast', locale: NS,
|
||||
inject: (): PluginRefreshToastFace => ({
|
||||
hooks: { pluginManager: face.hooks.pluginManager },
|
||||
dismissNotice: face.dismissNotice,
|
||||
}),
|
||||
}, PluginRefreshToast))
|
||||
ctx.slots.inject('main', function* () {
|
||||
const handle = createNavigationStore(), instance = handle.create()
|
||||
const store: typeof handle = { ...handle, create: () => instance }
|
||||
@@ -105,7 +114,7 @@ export function apply(ctx: ClientContext): void {
|
||||
key: PANEL_ID,
|
||||
locale: NS,
|
||||
store,
|
||||
inject: () => controller.inject(configLedger, text => ctx.locale.resolveText(text)),
|
||||
inject: () => face,
|
||||
children: {
|
||||
'plugins.item': { kind: 'list', scope: 'root' },
|
||||
'plugins.bundle.activation': { kind: 'keyed', scope: 'root' },
|
||||
|
||||
@@ -10,6 +10,7 @@ export const zh = {
|
||||
unavailable: '本部署没有可管理的 profile,无法安装或启停插件。',
|
||||
retry: '重试',
|
||||
refresh: '刷新',
|
||||
refreshError: '刷新失败,请重试',
|
||||
empty: '还没有安装任何插件。',
|
||||
addPlugin: '添加插件',
|
||||
restartNotice: '更改将在下次启动生效',
|
||||
@@ -48,11 +49,11 @@ export const zh = {
|
||||
installTitle: '添加插件',
|
||||
installDescription: '输入插件的包名、GitHub 仓库地址或本地目录路径。',
|
||||
installSpecLabel: '包名或地址',
|
||||
installSpecPlaceholder: '例如 @deepseek-ai/dsh-subagent-codex',
|
||||
installSpecPlaceholder: '例如 dsh-plugin-whale-pet',
|
||||
installGuideToggle: '插件安装引导和示例',
|
||||
installGuideHide: '收起引导',
|
||||
installGuideIdTitle: '包名',
|
||||
installGuideIdExample: '@deepseek-ai/dsh-subagent-codex',
|
||||
installGuideIdExample: 'dsh-plugin-whale-pet',
|
||||
installGuideIdHint: '插件包名即 npm 包名(如 dsh-xxx 或 @作者/插件名),社区插件的 README 安装命令中 dsh plugin add 或 pnpm add 之后的部分。',
|
||||
installGuideGitTitle: 'GitHub 仓库地址',
|
||||
installGuideGitExample: 'https://github.com/author/dsh-plugin',
|
||||
@@ -69,10 +70,9 @@ export const zh = {
|
||||
registryDefault: '默认安装源',
|
||||
registryOfficial: 'npm 官方源',
|
||||
registryNpmmirror: '中国大陆镜像源',
|
||||
registryWithHost: '{name}({host})',
|
||||
registryCustom: '自定义地址',
|
||||
registryCustomPlaceholder: 'https://npm.example.com/',
|
||||
registryCustomHint: '公司内网或私有 npm 源;需要登录的源,把凭据放在本机的 ~/.npmrc 里',
|
||||
registryCustomHint: '填写内网或私有 npm 源地址,以 http:// 或 https:// 开头。若为需要登录的源,请把凭据放在本机的 ~/.npmrc 里。',
|
||||
registryCustomInvalid: '请输入以 http:// 或 https:// 开头的地址',
|
||||
registryListSeparator: '、',
|
||||
installRun: '安装',
|
||||
@@ -203,6 +203,7 @@ export const en = {
|
||||
unavailable: 'This deployment runs without a manageable profile, so plugins cannot be installed or switched here.',
|
||||
retry: 'Retry',
|
||||
refresh: 'Refresh',
|
||||
refreshError: 'Refresh failed. Please try again.',
|
||||
empty: 'No plugins are installed yet.',
|
||||
addPlugin: 'Add plugin',
|
||||
restartNotice: 'The change takes effect at the next start',
|
||||
@@ -241,11 +242,11 @@ export const en = {
|
||||
installTitle: 'Add plugin',
|
||||
installDescription: 'Enter the plugin\'s package name, GitHub repository address, or local directory path.',
|
||||
installSpecLabel: 'Package name or address',
|
||||
installSpecPlaceholder: 'for example @deepseek-ai/dsh-subagent-codex',
|
||||
installSpecPlaceholder: 'for example dsh-plugin-whale-pet',
|
||||
installGuideToggle: 'Install guide and examples',
|
||||
installGuideHide: 'Hide the guide',
|
||||
installGuideIdTitle: 'Package name',
|
||||
installGuideIdExample: '@deepseek-ai/dsh-subagent-codex',
|
||||
installGuideIdExample: 'dsh-plugin-whale-pet',
|
||||
installGuideIdHint: 'The plugin package name is the npm package name (like dsh-xxx or @author/plugin): the part after dsh plugin add or pnpm add in a community plugin\'s README install command.',
|
||||
installGuideGitTitle: 'GitHub repository address',
|
||||
installGuideGitExample: 'https://github.com/author/dsh-plugin',
|
||||
@@ -262,10 +263,9 @@ export const en = {
|
||||
registryDefault: 'Default registry',
|
||||
registryOfficial: 'Official npm registry',
|
||||
registryNpmmirror: 'Mainland China mirror',
|
||||
registryWithHost: '{name} ({host})',
|
||||
registryCustom: 'Custom address',
|
||||
registryCustomPlaceholder: 'https://npm.example.com/',
|
||||
registryCustomHint: 'A company or private npm registry; for one that needs a login, keep the credentials in ~/.npmrc on this machine',
|
||||
registryCustomHint: 'Enter an internal or private npm registry address starting with http:// or https://. If it requires a login, store the credentials in ~/.npmrc on this machine.',
|
||||
registryCustomInvalid: 'Enter an address starting with http:// or https://',
|
||||
registryListSeparator: ', ',
|
||||
installRun: 'Install',
|
||||
|
||||
@@ -42,6 +42,7 @@ export type ManagerNotice =
|
||||
| { readonly kind: 'restart'; readonly packageName: string; readonly seq: number }
|
||||
| { readonly kind: 'overridden'; readonly packageName: string; readonly seq: number }
|
||||
| { readonly kind: 'cancelled'; readonly seq: number }
|
||||
| { readonly kind: 'refresh-failed'; readonly seq: number }
|
||||
| { readonly kind: 'install'; readonly outcome: 'done' | 'failed' | 'unconfirmed' | 'applying' | 'unknown'; readonly seq: number }
|
||||
| {
|
||||
readonly kind: 'failed'
|
||||
@@ -106,6 +107,9 @@ export type RegistryChoice =
|
||||
/** The choice shown until the Host has said which registry it asks first: the one pnpm's own configuration names. */
|
||||
const OFFICIAL_REGISTRY: RegistryChoice = { kind: 'offered', registry: null }
|
||||
|
||||
/** Hold the manual-refresh spinner at least this long so a fast read does not flash it. */
|
||||
const REFRESH_SPINNER_MIN_MS = 400
|
||||
|
||||
/**
|
||||
* The registry a choice asks, as the Host's install plan compares registries: pnpm's own configuration stands for
|
||||
* the URL it names, once the Host has read it.
|
||||
@@ -270,6 +274,12 @@ export interface ConfirmState {
|
||||
export interface PluginManagerState {
|
||||
/** `unavailable` when the Host runs without a managed profile; `error` keeps the last packages. */
|
||||
readonly status: 'idle' | 'loading' | 'ready' | 'error' | 'unavailable'
|
||||
/**
|
||||
* Manual refresh feedback: `refreshing` until reads settle and the 400 ms minimum elapses;
|
||||
* `failed` after a failed refresh without cached inventory; otherwise `idle`, including
|
||||
* cached refresh failures reported by toast. Background reads do not start the spinner.
|
||||
*/
|
||||
readonly refreshStatus: 'idle' | 'refreshing' | 'failed'
|
||||
readonly packages: readonly PackageView[]
|
||||
/** Package names and row keys with an action crossing the wire. */
|
||||
readonly busy: readonly string[]
|
||||
@@ -493,6 +503,8 @@ export class PluginManagerController {
|
||||
private rerun = false
|
||||
private generation = 0
|
||||
private disposed = false
|
||||
/** A successful managed-profile read remains usable even when it returned no bundles. */
|
||||
private hasCachedInventory = false
|
||||
private pendingConfirm: (() => Promise<void>) | undefined
|
||||
/** Cancels the check the dialog has in flight. */
|
||||
private inspectAbort: AbortController | undefined
|
||||
@@ -512,7 +524,7 @@ export class PluginManagerController {
|
||||
private readonly ctx: ClientContext,
|
||||
) {
|
||||
this.store = createSnapshotStore<PluginManagerState>({
|
||||
status: 'idle', packages: [], busy: [], notice: null,
|
||||
status: 'idle', refreshStatus: 'idle', packages: [], busy: [], notice: null,
|
||||
install: IDLE_INSTALL, confirm: null, highlight: null,
|
||||
})
|
||||
}
|
||||
@@ -545,7 +557,7 @@ export class PluginManagerController {
|
||||
hooks: { pluginManager: this.store, configLedger, configurations: this.ctx.configForms.describe() },
|
||||
configForm: id => this.ctx.configForms.get(id),
|
||||
ensure: () => { if (this.getSnapshot().status === 'idle') void this.load() },
|
||||
refresh: () => { void this.load() },
|
||||
refresh: () => { void this.refresh() },
|
||||
openInstall: () => {
|
||||
this.ctx.get('productAnalytics')?.track('plugin_add_button_click', {})
|
||||
const install = this.getSnapshot().install
|
||||
@@ -719,6 +731,36 @@ export class PluginManagerController {
|
||||
return run
|
||||
}
|
||||
|
||||
/** Keep manual refresh feedback until its coalesced reads settle, without clearing cached cards. */
|
||||
private async refresh(): Promise<void> {
|
||||
if (this.disposed || this.getSnapshot().refreshStatus === 'refreshing') return
|
||||
const startedAt = Date.now()
|
||||
this.patch({
|
||||
refreshStatus: 'refreshing',
|
||||
...this.getSnapshot().notice?.kind === 'refresh-failed' ? { notice: null } : {},
|
||||
})
|
||||
try {
|
||||
await this.load()
|
||||
} catch (_error) {
|
||||
// A rejected transport request leaves the cached cards available for retry.
|
||||
this.patch({ status: 'error' })
|
||||
} finally {
|
||||
// Hold the spinner to its minimum so a fast read does not flash it, then settle.
|
||||
const remaining = REFRESH_SPINNER_MIN_MS - (Date.now() - startedAt)
|
||||
if (remaining > 0) await new Promise<void>((resolve) => { setTimeout(resolve, remaining) })
|
||||
this.settleRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
/** Publish refresh feedback only before disposal. */
|
||||
private settleRefresh(): void {
|
||||
if (this.disposed) return
|
||||
const failed = this.getSnapshot().status === 'error'
|
||||
this.patch(failed && this.hasCachedInventory
|
||||
? { status: 'ready', refreshStatus: 'idle', notice: { kind: 'refresh-failed', seq: ++this.noticeSeq } }
|
||||
: { refreshStatus: failed ? 'failed' : 'idle' })
|
||||
}
|
||||
|
||||
private async read(): Promise<void> {
|
||||
try {
|
||||
do {
|
||||
@@ -734,6 +776,7 @@ export class PluginManagerController {
|
||||
continue
|
||||
}
|
||||
if (inventory.value.managementAvailable !== true) {
|
||||
this.hasCachedInventory = false
|
||||
this.patch({ status: 'unavailable', packages: [] })
|
||||
continue
|
||||
}
|
||||
@@ -746,8 +789,10 @@ export class PluginManagerController {
|
||||
this.patch({ status: 'error' })
|
||||
continue
|
||||
}
|
||||
this.hasCachedInventory = true
|
||||
this.patch({
|
||||
status: 'ready',
|
||||
refreshStatus: this.getSnapshot().refreshStatus === 'refreshing' ? 'refreshing' : 'idle',
|
||||
packages: sortPackages(bundles.value.map(bundle => packageView(bundle, plugins.value))),
|
||||
})
|
||||
} while (this.shouldRerun())
|
||||
@@ -802,7 +847,7 @@ export class PluginManagerController {
|
||||
const typed = choice.kind === 'custom' ? choice.url.trim() : undefined
|
||||
if (typed !== undefined && !REGISTRY_URL.test(typed)) {
|
||||
this.finishAnalytics('failed', 'invalid-registry')
|
||||
this.patchInstall({ phase: 'idle', registryError: true })
|
||||
this.patchInstall({ phase: 'idle', registryError: true, registryOpen: true })
|
||||
return
|
||||
}
|
||||
this.abortInspect()
|
||||
|
||||
@@ -143,6 +143,7 @@ export function noticeText(notice: ManagerNotice, t: Translate): string {
|
||||
case 'restart': return t('restartNotice')
|
||||
case 'overridden': return t('overriddenNotice', { name: notice.packageName })
|
||||
case 'cancelled': return t('installCancelled')
|
||||
case 'refresh-failed': return t('refreshError')
|
||||
case 'install': return t(({
|
||||
done: 'installBackgroundDone', failed: 'installBackgroundFailed',
|
||||
unconfirmed: 'installBackgroundUnconfirmed', applying: 'installBackgroundApplying', unknown: 'installBackgroundUnknown',
|
||||
|
||||
@@ -12,6 +12,7 @@ import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-t
|
||||
import * as settings from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { apply, inject, NS, PANEL_ID } from '../src/client/index.ts'
|
||||
import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx'
|
||||
import { PluginRefreshToast, type PluginRefreshToastFace } from '../src/client/PluginRefreshToast.tsx'
|
||||
import { PluginsPanelIcon } from '../src/client/PluginsPanelIcon.tsx'
|
||||
import type { PluginManagerFace } from '../src/client/manager-store.ts'
|
||||
|
||||
@@ -54,6 +55,7 @@ function declare(slots: SlotRegistry): () => void {
|
||||
name: 'root',
|
||||
children: {
|
||||
'main': { kind: 'keyed', scope: 'root' },
|
||||
'shell.overlay': { kind: 'list', scope: 'root' },
|
||||
'sidebar.panellist': { kind: 'list', scope: 'root' },
|
||||
},
|
||||
} as never, () => null)
|
||||
@@ -80,6 +82,41 @@ describe('ui-plugin-manager browser plugin', () => {
|
||||
expect(navigation.getSnapshot()).toEqual({ view: { kind: 'package', name: 'dsh-navigation-test' } })
|
||||
})
|
||||
|
||||
it('shares refresh failures with the overlay after navigation and releases both registrations across reloads', async () => {
|
||||
const b = await bench()
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(b.slots.entries('shell.overlay')).toHaveLength(0)
|
||||
const removeRoot = declare(b.slots)
|
||||
const mainInjected: object = b.slots.entries('main')[0]!.inject!()
|
||||
const face = mainInjected as PluginManagerFace
|
||||
const overlayInjected: object = b.slots.entries('shell.overlay')[0]!.inject!()
|
||||
const overlay = overlayInjected as PluginRefreshToastFace
|
||||
expect(overlay.hooks.pluginManager).toBe(face.hooks.pluginManager)
|
||||
face.ensure()
|
||||
await vi.waitFor(() => { expect(face.hooks.pluginManager.getSnapshot().status).toBe('ready') })
|
||||
b.selectPanel(PANEL_ID)
|
||||
b.list.mockRejectedValueOnce(new Error('offline'))
|
||||
face.refresh()
|
||||
b.selectPanel(null)
|
||||
await vi.waitFor(() => { expect(overlay.hooks.pluginManager.getSnapshot().notice?.kind).toBe('refresh-failed') })
|
||||
overlay.dismissNotice()
|
||||
expect(face.hooks.pluginManager.getSnapshot().notice).toBeNull()
|
||||
removeRoot()
|
||||
expect(b.slots.entries('shell.overlay')).toHaveLength(0)
|
||||
expect(b.slots.entries('main')).toHaveLength(0)
|
||||
declare(b.slots)
|
||||
const remountedInjected: object = b.slots.entries('shell.overlay')[0]!.inject!()
|
||||
expect((remountedInjected as PluginRefreshToastFace).hooks.pluginManager).toBe(face.hooks.pluginManager)
|
||||
await fiber.dispose()
|
||||
expect(b.slots.entries('shell.overlay')).toHaveLength(0)
|
||||
expect(b.slots.entries('main')).toHaveLength(0)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const reloadedInjected: object = b.slots.entries('shell.overlay')[0]!.inject!()
|
||||
expect((reloadedInjected as PluginRefreshToastFace).hooks.pluginManager).not.toBe(face.hooks.pluginManager)
|
||||
expect((reloadedInjected as PluginRefreshToastFace).hooks.pluginManager.getSnapshot().notice).toBeNull()
|
||||
})
|
||||
|
||||
it('declares only the services the page and its Remote methods use', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout'])
|
||||
})
|
||||
@@ -116,7 +153,18 @@ describe('ui-plugin-manager browser plugin', () => {
|
||||
for (const name of ['plugins.detail.actions', 'plugins.detail.badge', 'plugins.detail.section'] as const) {
|
||||
expect(b.slots.spec(name)).toMatchObject({ kind: 'list', scope: 'root' })
|
||||
}
|
||||
const face = (entry.inject as unknown as () => PluginManagerFace)()
|
||||
const injected: object = entry.inject!()
|
||||
const face = injected as PluginManagerFace
|
||||
const overlay = b.slots.entries('shell.overlay')[0]!
|
||||
expect(overlay.component).toBe(PluginRefreshToast)
|
||||
expect(overlay.options).toMatchObject({ id: 'plugin-manager.refresh-toast' })
|
||||
expect(overlay.locale).toBe(NS)
|
||||
const overlayInjected: object = overlay.inject!()
|
||||
const overlayFace = overlayInjected as PluginRefreshToastFace
|
||||
expect(overlayFace.hooks.pluginManager).toBe(face.hooks.pluginManager)
|
||||
expect(overlayFace.dismissNotice).toBe(face.dismissNotice)
|
||||
expect(Object.keys(overlayFace).sort()).toEqual(['dismissNotice', 'hooks'])
|
||||
expect(Object.keys(overlayFace.hooks)).toEqual(['pluginManager'])
|
||||
const text = { en: 'Local tools', zh: '本地工具' }
|
||||
expect(face.resolveText(text)).toBe('本地工具')
|
||||
b.locale.setLocale('en')
|
||||
@@ -146,6 +194,7 @@ describe('ui-plugin-manager browser plugin', () => {
|
||||
await fiber.dispose()
|
||||
expect(b.ctx.get('pluginNavigation')).toBeUndefined()
|
||||
expect(b.slots.entries('main')).toHaveLength(0)
|
||||
expect(b.slots.entries('shell.overlay')).toHaveLength(0)
|
||||
expect(b.slots.entries('sidebar.panellist')).toHaveLength(0)
|
||||
b.remote.emit('plugin-manager/changed', [{ reason: 'install' }])
|
||||
await Promise.resolve()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { ConfigPageForm } from '../src/client/slot-contract.ts'
|
||||
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { PluginEntryId, PluginInstallRequestId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
@@ -11,6 +11,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { StrictMode, type ReactNode } from 'react'
|
||||
import { createNavigationStore } from '../src/client/navigation-store.ts'
|
||||
import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx'
|
||||
import { PluginRefreshToast } from '../src/client/PluginRefreshToast.tsx'
|
||||
import type { PluginManagerPageProps } from '../src/client/index.ts'
|
||||
import type { ConfigLedger } from '../src/client/config-ledger.ts'
|
||||
import { rowKey, type InstallState, type PackageRow, type PackageView, type PluginManagerState } from '../src/client/manager-store.ts'
|
||||
@@ -54,9 +55,9 @@ const OFFICIAL = 'https://registry.npmjs.org/'
|
||||
const REGISTRIES = { registry: null, fallbackRegistries: [MIRROR], resolved: OFFICIAL }
|
||||
|
||||
/** pnpm's own registry as the options label it: by npm's own name once read, the neutral default name until then. */
|
||||
const OFFICIAL_OPTION = en.registryWithHost.replace('{name}', en.registryOfficial).replace('{host}', 'registry.npmjs.org')
|
||||
const UNREAD_OPTION = en.registryWithHost.replace('{name}', en.registryDefault).replace('{host}', 'registry.npmjs.org')
|
||||
const MIRROR_OPTION = en.registryWithHost.replace('{name}', en.registryNpmmirror).replace('{host}', 'registry.npmmirror.com')
|
||||
const OFFICIAL_OPTION = `${en.registryOfficial} registry.npmjs.org`
|
||||
const UNREAD_OPTION = `${en.registryDefault} registry.npmjs.org`
|
||||
const MIRROR_OPTION = `${en.registryNpmmirror} registry.npmmirror.com`
|
||||
|
||||
const IDLE_INSTALL: InstallState = {
|
||||
open: false, spec: '', registries: null, registry: { kind: 'offered', registry: null }, registryOpen: false, registryError: false, attempts: null,
|
||||
@@ -66,6 +67,7 @@ const IDLE_INSTALL: InstallState = {
|
||||
|
||||
const READY: PluginManagerState = {
|
||||
status: 'ready',
|
||||
refreshStatus: 'idle',
|
||||
packages: [],
|
||||
busy: [],
|
||||
notice: null,
|
||||
@@ -163,9 +165,18 @@ function renderTab(
|
||||
return body(owner.view, owner, 'form' in owner ? owner.form as ConfigPageForm | undefined : undefined)
|
||||
},
|
||||
}
|
||||
const { rerender, unmount } = render(<PluginManagerPage {...props} />)
|
||||
let showPage = true
|
||||
let currentT = t
|
||||
const contents = () => (
|
||||
<>
|
||||
{showPage ? <PluginManagerPage {...props} t={currentT} /> : null}
|
||||
<PluginRefreshToast usePluginManager={props.usePluginManager} dismissNotice={actions.dismissNotice} t={currentT} />
|
||||
</>
|
||||
)
|
||||
const { rerender, unmount } = render(contents())
|
||||
return {
|
||||
navigation,
|
||||
hidePage: () => { showPage = false; rerender(contents()) },
|
||||
props,
|
||||
unmount,
|
||||
store,
|
||||
@@ -173,7 +184,8 @@ function renderTab(
|
||||
set: (next: Partial<PluginManagerState>) => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) },
|
||||
setLanguage: (dict: typeof en) => {
|
||||
locale.setLocale(dict === zh ? 'zh' : 'en')
|
||||
rerender(<PluginManagerPage {...props} t={translate(dict)} />)
|
||||
currentT = translate(dict)
|
||||
rerender(contents())
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -203,9 +215,14 @@ describe('PluginManagerPage', () => {
|
||||
it('asks the store once mounted and renders the loading, unavailable, error, and empty states', () => {
|
||||
const { actions, set } = renderTab({ status: 'loading' })
|
||||
expect(actions.ensure).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByText(en.loading).querySelector('[data-state="ongoing"]')).not.toBeNull()
|
||||
const loading = screen.getByRole('status', { name: en.loading })
|
||||
expect(loading.querySelectorAll('li')).toHaveLength(4)
|
||||
expect(loading.querySelector('ul')?.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(loading.querySelector('button, input, [data-state="ongoing"]')).toBeNull()
|
||||
expect(document.querySelector('[data-plugin-panel]')?.getAttribute('aria-busy')).toBe('true')
|
||||
expect(screen.getByRole('button', { name: en.addPlugin })).toHaveProperty('disabled', true)
|
||||
set({ status: 'unavailable' })
|
||||
expect(screen.queryByRole('status', { name: en.loading })).toBeNull()
|
||||
expect(screen.getByRole('status').querySelector('[data-state="idle"]')).not.toBeNull()
|
||||
set({ status: 'error' })
|
||||
expect(screen.getByRole('alert').querySelector('[data-state="error"]')).not.toBeNull()
|
||||
@@ -219,6 +236,134 @@ describe('PluginManagerPage', () => {
|
||||
expect(actions.openInstall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows skeletons only while loading and keeps ready cards during refresh and switching', () => {
|
||||
const { actions, set } = renderTab({ status: 'idle' })
|
||||
expect(screen.queryByRole('status', { name: en.loading })).toBeNull()
|
||||
set({ status: 'loading' })
|
||||
expect(screen.getByRole('status', { name: en.loading })).toBeTruthy()
|
||||
set({ status: 'ready', packages: [pkg()] })
|
||||
expect(screen.queryByRole('status', { name: en.loading })).toBeNull()
|
||||
expect(document.querySelector('[data-plugin-panel]')?.getAttribute('aria-busy')).toBe('false')
|
||||
fireEvent.click(screen.getByRole('button', { name: en.refresh }))
|
||||
expect(actions.refresh).toHaveBeenCalledTimes(1)
|
||||
set({ busy: ['dsh-better-sidebar'] })
|
||||
expect(screen.queryByRole('status', { name: en.loading })).toBeNull()
|
||||
expect(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })).toBeTruthy()
|
||||
expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'dsh-better-sidebar') })).toHaveProperty('disabled', true)
|
||||
})
|
||||
|
||||
it('shows refresh progress on the disabled button while preserving cards, then restores it without a toast', () => {
|
||||
const { actions, set } = renderTab({ packages: [pkg()] })
|
||||
const refresh = screen.getByRole('button', { name: en.refresh })
|
||||
const detailName = en.openDetail.replace('{name}', 'dsh-better-sidebar')
|
||||
const card = screen.getByRole('button', { name: detailName })
|
||||
expect(refresh).toHaveProperty('disabled', false)
|
||||
expect(refresh.getAttribute('title')).toBeNull()
|
||||
fireEvent.click(refresh)
|
||||
expect(actions.refresh).toHaveBeenCalledOnce()
|
||||
set({ refreshStatus: 'refreshing' })
|
||||
expect(refresh).toHaveProperty('disabled', true)
|
||||
expect(refresh.getAttribute('aria-busy')).toBe('true')
|
||||
expect(refresh.querySelector('[data-state="ongoing"]')).not.toBeNull()
|
||||
expect(screen.getByRole('button', { name: detailName })).toBe(card)
|
||||
expect(screen.queryByRole('status', { name: en.loading })).toBeNull()
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
fireEvent.click(refresh)
|
||||
expect(actions.refresh).toHaveBeenCalledOnce()
|
||||
set({ refreshStatus: 'idle' })
|
||||
expect(refresh).toHaveProperty('disabled', false)
|
||||
expect(refresh.getAttribute('aria-busy')).toBe('false')
|
||||
expect(refresh.querySelector('[data-state="ongoing"]')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: detailName })).toBe(card)
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
expect(actions.dismissNotice).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['list', 'detail'] as const)('keeps cached %s content with one localized refresh toast and no inline retry', (view) => {
|
||||
const { set, setLanguage } = renderTab({ packages: [pkg()] })
|
||||
if (view === 'detail') fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
|
||||
const content = document.querySelector(view === 'detail' ? '[data-plugin-detail]' : '[data-plugin-package]')
|
||||
for (const dict of [en, zh]) {
|
||||
setLanguage(dict)
|
||||
set({ status: 'ready', refreshStatus: 'idle', notice: { kind: 'refresh-failed', seq: 1 } })
|
||||
expect(screen.getAllByRole('alert')).toHaveLength(1)
|
||||
const toast = screen.getByRole('alert')
|
||||
expect(toast.textContent).toBe(dict.refreshError)
|
||||
expect(toast.parentElement).toBe(document.body)
|
||||
expect(document.querySelector('[data-plugin-panel]')?.contains(toast)).toBe(false)
|
||||
expect(screen.queryByRole('button', { name: dict.retry })).toBeNull()
|
||||
expect(content?.isConnected).toBe(true)
|
||||
set({ refreshStatus: 'refreshing', notice: null })
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
expect(content?.isConnected).toBe(true)
|
||||
set({ refreshStatus: 'idle' })
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['list', 'configuration detail'] as const)('keeps an uncached refresh failure inline in %s and hides it during retry', (view) => {
|
||||
const { actions, set, setLanguage } = renderTab(
|
||||
{ status: 'error', refreshStatus: 'failed' },
|
||||
{ items: [{ id: 'bash', label: 'Shell' }] },
|
||||
)
|
||||
if (view === 'configuration detail') fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'Shell') }))
|
||||
for (const dict of [en, zh]) {
|
||||
setLanguage(dict)
|
||||
set({ status: 'error', refreshStatus: 'failed' })
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert.textContent).toBe(dict.refreshError)
|
||||
expect(document.querySelector('[data-plugin-panel]')?.contains(alert)).toBe(true)
|
||||
fireEvent.click(screen.getByRole('button', { name: dict.retry }))
|
||||
set({ refreshStatus: 'refreshing' })
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: dict.retry })).toBeNull()
|
||||
}
|
||||
expect(actions.refresh).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it.each(['before', 'after'] as const)('keeps the refresh toast when navigation leaves %s the failure arrives', (navigation) => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { actions, set, hidePage } = renderTab({ packages: [pkg()] })
|
||||
set({ refreshStatus: 'refreshing' })
|
||||
if (navigation === 'before') hidePage()
|
||||
set({ refreshStatus: 'idle', notice: { kind: 'refresh-failed', seq: 1 } })
|
||||
const toast = screen.getByRole('alert')
|
||||
expect(toast.textContent).toBe(en.refreshError)
|
||||
if (navigation === 'after') hidePage()
|
||||
expect(document.querySelector('[data-plugin-panel]')).toBeNull()
|
||||
expect(screen.getByRole('alert')).toBe(toast)
|
||||
expect(toast.style.getPropertyValue('--dsh-toast-hold')).toBe('3000ms')
|
||||
act(() => { vi.advanceTimersByTime(3_999) })
|
||||
expect(actions.dismissNotice).not.toHaveBeenCalled()
|
||||
act(() => { vi.advanceTimersByTime(1) })
|
||||
expect(actions.dismissNotice).toHaveBeenCalledOnce()
|
||||
set({ notice: null })
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
} finally {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('gives a repeated refresh failure its own toast lifetime without extending it for locale changes', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { actions, set, setLanguage } = renderTab({ notice: { kind: 'refresh-failed', seq: 1 } })
|
||||
act(() => { vi.advanceTimersByTime(2_000) })
|
||||
set({ notice: { kind: 'refresh-failed', seq: 2 } })
|
||||
act(() => { vi.advanceTimersByTime(2_000) })
|
||||
expect(actions.dismissNotice).not.toHaveBeenCalled()
|
||||
setLanguage(zh)
|
||||
expect(screen.getByRole('alert').textContent).toBe(zh.refreshError)
|
||||
act(() => { vi.advanceTimersByTime(2_000) })
|
||||
expect(actions.dismissNotice).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the read failure and its retry visible while a detail page is open', () => {
|
||||
const { actions, set } = renderTab({ packages: [pkg()] })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
|
||||
@@ -735,6 +880,19 @@ describe('PluginManagerPage', () => {
|
||||
expect(document.querySelector('[data-plugin-name]')?.textContent).toBe(name)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ locale: en, placeholder: 'for example dsh-plugin-whale-pet' },
|
||||
{ locale: zh, placeholder: '例如 dsh-plugin-whale-pet' },
|
||||
])('uses the package example in $placeholder and the install guide', ({ locale, placeholder }) => {
|
||||
const { actions, setLanguage } = renderTab({ install: { ...IDLE_INSTALL, open: true } })
|
||||
setLanguage(locale)
|
||||
expect(screen.getByPlaceholderText(placeholder)).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: locale.installGuideToggle }))
|
||||
expect(screen.getByText('dsh-plugin-whale-pet')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: locale.installGuideFillAria.replace('{example}', 'dsh-plugin-whale-pet') }))
|
||||
expect(actions.editInstallSpec).toHaveBeenCalledExactlyOnceWith('dsh-plugin-whale-pet')
|
||||
})
|
||||
|
||||
it('opens a guide under the field and drops an example into it', () => {
|
||||
const { actions } = renderTab({ install: { ...IDLE_INSTALL, open: true } })
|
||||
expect(screen.queryByText(en.installGuideIdHint)).toBeNull()
|
||||
@@ -857,6 +1015,100 @@ describe('PluginManagerPage', () => {
|
||||
expect(within(detail).getByText(en.rowStateIdle)).toBeTruthy()
|
||||
})
|
||||
|
||||
describe.each(['package', 'custom registry'] as const)('%s input IME confirmation', (input) => {
|
||||
beforeEach(() => { vi.useFakeTimers() })
|
||||
afterEach(() => { cleanup(); vi.useRealTimers() })
|
||||
const inputName = input === 'package' ? en.installSpecLabel : en.registryCustom
|
||||
const open: InstallState = {
|
||||
...IDLE_INSTALL, open: true, spec: 'dsh-new', registryOpen: true,
|
||||
registry: { kind: 'custom', url: 'https://npm.corp.example/' },
|
||||
}
|
||||
const enter = (field: HTMLElement) => {
|
||||
// oxlint-disable-next-line typescript/no-deprecated -- Exercise native Enter's legacy keyCode in the IME regression.
|
||||
const event = new KeyboardEvent('keydown', { key: 'Enter', isComposing: false, keyCode: 13, bubbles: true, cancelable: true })
|
||||
fireEvent(field, event)
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
}
|
||||
|
||||
it('guards active composition and the first 10ms after compositionend without native flags', () => {
|
||||
const { actions } = renderTab({ install: open })
|
||||
const field = screen.getByRole('textbox', { name: inputName })
|
||||
fireEvent.compositionStart(field)
|
||||
enter(field)
|
||||
expect(actions.runInstall).not.toHaveBeenCalled()
|
||||
fireEvent.compositionEnd(field)
|
||||
enter(field)
|
||||
act(() => { vi.advanceTimersByTime(9) })
|
||||
enter(field)
|
||||
expect(actions.runInstall).not.toHaveBeenCalled()
|
||||
act(() => { vi.advanceTimersByTime(1) })
|
||||
enter(field)
|
||||
expect(actions.runInstall).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps composition state independent from the other input', () => {
|
||||
const { actions } = renderTab({ install: open })
|
||||
const field = screen.getByRole('textbox', { name: inputName })
|
||||
const other = screen.getByRole('textbox', { name: input === 'package' ? en.registryCustom : en.installSpecLabel })
|
||||
fireEvent.compositionStart(field)
|
||||
enter(other)
|
||||
expect(actions.runInstall).toHaveBeenCalledOnce()
|
||||
enter(field)
|
||||
expect(actions.runInstall).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['active', 'ended'] as const)('clears %s composition on blur and dialog reopen', (phase) => {
|
||||
const { actions, set } = renderTab({ install: open })
|
||||
const field = screen.getByRole('textbox', { name: inputName })
|
||||
fireEvent.compositionStart(field)
|
||||
if (phase === 'ended') fireEvent.compositionEnd(field)
|
||||
fireEvent.blur(field)
|
||||
enter(field)
|
||||
expect(actions.runInstall).toHaveBeenCalledOnce()
|
||||
fireEvent.compositionStart(field)
|
||||
if (phase === 'ended') fireEvent.compositionEnd(field)
|
||||
set({ install: { ...open, open: false } })
|
||||
set({ install: open })
|
||||
enter(screen.getByRole('textbox', { name: inputName }))
|
||||
expect(actions.runInstall).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
if (input === 'custom registry') {
|
||||
it('clears composition when the registry menu closes and reopens', () => {
|
||||
const { actions, set } = renderTab({ install: open })
|
||||
fireEvent.compositionStart(screen.getByRole('textbox', { name: inputName }))
|
||||
set({ install: { ...open, registryOpen: false } })
|
||||
set({ install: open })
|
||||
enter(screen.getByRole('textbox', { name: inputName }))
|
||||
expect(actions.runInstall).toHaveBeenCalledOnce()
|
||||
})
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ signal: 'isComposing', isComposing: true, keyCode: 13 },
|
||||
{ signal: 'legacy keyCode 229', isComposing: false, keyCode: 229 },
|
||||
])('leaves $signal Enter to the IME and installs on the next ordinary Enter', ({ isComposing, keyCode }) => {
|
||||
const { actions } = renderTab({
|
||||
install: {
|
||||
...IDLE_INSTALL, open: true, spec: 'dsh-new', registryOpen: input === 'custom registry',
|
||||
registry: { kind: 'custom', url: 'https://npm.corp.example/' },
|
||||
},
|
||||
})
|
||||
const field = screen.getByRole('textbox', { name: input === 'package' ? en.installSpecLabel : en.registryCustom })
|
||||
fireEvent.compositionStart(field)
|
||||
if (!isComposing) fireEvent.compositionEnd(field)
|
||||
// oxlint-disable-next-line typescript/no-deprecated -- Exercise the legacy IME signal when isComposing is false.
|
||||
const confirm = new KeyboardEvent('keydown', { key: 'Enter', isComposing, keyCode, bubbles: true, cancelable: true })
|
||||
fireEvent(field, confirm)
|
||||
expect(actions.runInstall).not.toHaveBeenCalled()
|
||||
expect(confirm.defaultPrevented).toBe(false)
|
||||
if (isComposing) fireEvent.compositionEnd(field)
|
||||
act(() => { vi.advanceTimersByTime(10) })
|
||||
fireEvent.keyDown(field, { key: 'Enter', isComposing: false, keyCode: 13 })
|
||||
expect(actions.runInstall).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
it('takes a spec, checks it, and words what the check refused', () => {
|
||||
const { actions, set } = renderTab({ install: { ...IDLE_INSTALL, open: true } })
|
||||
expect(screen.getByText(en.installDescription)).toBeTruthy()
|
||||
@@ -1197,6 +1449,86 @@ describe('PluginManagerPage', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('selects the custom registry from its whole area and focuses the address without clearing it', () => {
|
||||
const open = { ...IDLE_INSTALL, open: true, registryOpen: true, registries: REGISTRIES }
|
||||
const { actions, set } = renderTab({ install: open })
|
||||
const field = screen.getByRole('textbox', { name: en.registryCustom })
|
||||
const label = screen.getByRole('radio', { name: en.registryCustom }).parentElement!
|
||||
const area = label.parentElement!
|
||||
expect(field).toHaveProperty('disabled', false)
|
||||
for (const target of [area, label, screen.getByText(en.registryCustomHint)]) {
|
||||
field.blur()
|
||||
set({ install: open })
|
||||
actions.chooseRegistry.mockClear()
|
||||
fireEvent.pointerDown(target)
|
||||
fireEvent.click(target)
|
||||
expect(actions.chooseRegistry).toHaveBeenCalledExactlyOnceWith({ kind: 'custom', url: '' })
|
||||
expect(document.activeElement).toBe(field)
|
||||
}
|
||||
set({ install: { ...open, registry: { kind: 'custom', url: 'https://npm.corp/' } } })
|
||||
field.blur()
|
||||
actions.chooseRegistry.mockClear()
|
||||
field.focus()
|
||||
fireEvent.click(field)
|
||||
fireEvent.click(area)
|
||||
expect(document.activeElement).toBe(field)
|
||||
expect(field).toHaveProperty('value', 'https://npm.corp/')
|
||||
expect(actions.chooseRegistry).not.toHaveBeenCalled()
|
||||
field.blur()
|
||||
fireEvent.click(screen.getByRole('radio', { name: OFFICIAL_OPTION }))
|
||||
set({ install: open })
|
||||
expect(field).toHaveProperty('value', 'https://npm.corp/')
|
||||
fireEvent.click(screen.getByRole('radio', { name: MIRROR_OPTION }))
|
||||
set({ install: { ...open, registry: { kind: 'offered', registry: MIRROR } } })
|
||||
fireEvent.click(area)
|
||||
expect(actions.chooseRegistry).toHaveBeenLastCalledWith({ kind: 'custom', url: 'https://npm.corp/' })
|
||||
expect(document.activeElement).toBe(field)
|
||||
})
|
||||
|
||||
it('keeps the selected registry on keyboard focus and selects custom through its radio', () => {
|
||||
const open = { ...IDLE_INSTALL, open: true, registryOpen: true, registries: REGISTRIES }
|
||||
const { actions } = renderTab({ install: open })
|
||||
const field = screen.getByRole('textbox', { name: en.registryCustom })
|
||||
const official = screen.getByRole('radio', { name: OFFICIAL_OPTION })
|
||||
official.focus()
|
||||
fireEvent.keyDown(official, { key: 'Tab' })
|
||||
expect(document.activeElement).toBe(field)
|
||||
expect(official).toHaveProperty('checked', true)
|
||||
expect(actions.chooseRegistry).not.toHaveBeenCalled()
|
||||
fireEvent.keyDown(field, { key: 'Tab', shiftKey: true })
|
||||
expect(document.activeElement).toBe(official)
|
||||
fireEvent.keyDown(official, { key: 'Tab', shiftKey: true })
|
||||
const toggle = screen.getByRole('button', { name: `${en.registryToggle} ${en.registryOfficial}` })
|
||||
expect(document.activeElement).toBe(toggle)
|
||||
expect(actions.toggleRegistryOptions).toHaveBeenCalledOnce()
|
||||
actions.toggleRegistryOptions.mockClear()
|
||||
field.focus()
|
||||
fireEvent.keyDown(field, { key: 'Tab' })
|
||||
expect(document.activeElement).toBe(toggle)
|
||||
expect(actions.toggleRegistryOptions).toHaveBeenCalledOnce()
|
||||
const custom = screen.getByRole('radio', { name: en.registryCustom })
|
||||
custom.focus()
|
||||
fireEvent.keyDown(custom, { key: ' ' })
|
||||
fireEvent.click(custom)
|
||||
expect(actions.chooseRegistry).toHaveBeenCalledExactlyOnceWith({ kind: 'custom', url: '' })
|
||||
expect(document.activeElement).toBe(field)
|
||||
})
|
||||
|
||||
it('focuses an invalid custom address when submission opens its registry menu', () => {
|
||||
const open = { ...IDLE_INSTALL, open: true, spec: 'dsh-new', registry: { kind: 'custom' as const, url: 'invalid' } }
|
||||
const { actions, set } = renderTab({ install: open })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.installRun }))
|
||||
expect(actions.runInstall).toHaveBeenCalledOnce()
|
||||
set({ install: { ...open, registryOpen: true, registryError: true } })
|
||||
const field = screen.getByRole('textbox', { name: en.registryCustom })
|
||||
expect(document.activeElement).toBe(field)
|
||||
expect(field.getAttribute('aria-invalid')).toBe('true')
|
||||
expect(screen.getByRole('alert').textContent).toBe(en.registryCustomInvalid)
|
||||
fireEvent.keyDown(field, { key: 'Escape' })
|
||||
expect(actions.toggleRegistryOptions).toHaveBeenCalledOnce()
|
||||
expect(actions.closeInstall).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('offers the registries under the spec, folded by default, and picks or types one', () => {
|
||||
const open = { ...IDLE_INSTALL, open: true, registries: REGISTRIES }
|
||||
const { actions, set } = renderTab({ install: open })
|
||||
|
||||
@@ -74,7 +74,9 @@ const NO_CONFIG: HostObservable<ConfigLedger> = {
|
||||
function bench(overrides: Partial<Record<string, ReturnType<typeof vi.fn>>> = {}, enabled = true) {
|
||||
const inventory = { list: overrides.inventory ?? vi.fn(() => Promise.resolve(ok({ entries: [], managementAvailable: true }))) }
|
||||
const plugins = {
|
||||
listBundles: vi.fn(() => Promise.resolve(ok([BUNDLE]))),
|
||||
listBundles: vi.fn<() => Promise<ReturnType<typeof ok<BundleInfo[]>> | ReturnType<typeof refused>>>(
|
||||
() => Promise.resolve(ok([BUNDLE])),
|
||||
),
|
||||
listPlugins: vi.fn(() => Promise.resolve(ok(PLUGINS))),
|
||||
inspect: vi.fn(() => Promise.resolve(ok(INSPECTED))),
|
||||
registries: vi.fn(() => Promise.resolve(ok(REGISTRIES))),
|
||||
@@ -226,7 +228,229 @@ describe('PluginManagerController', () => {
|
||||
expect(plugins.listBundles).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps cached packages while a manual refresh reads and ignores repeated refreshes without a success notice', async () => {
|
||||
vi.useFakeTimers()
|
||||
onTestFinished(() => { vi.useRealTimers() })
|
||||
const { plugins, inventory, face, state, controller } = bench()
|
||||
await controller.load()
|
||||
const packages = state().packages
|
||||
const gate = deferred<ReturnType<typeof ok<BundleInfo[]>>>()
|
||||
plugins.listBundles.mockReturnValueOnce(gate.promise)
|
||||
face.refresh()
|
||||
expect(state()).toMatchObject({ status: 'ready', refreshStatus: 'refreshing', notice: null })
|
||||
expect(state().packages).toBe(packages)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(plugins.listBundles).toHaveBeenCalledTimes(2)
|
||||
face.refresh()
|
||||
face.refresh()
|
||||
gate.resolve(ok([{ ...BUNDLE, version: '0.17.0' }]))
|
||||
await vi.advanceTimersByTimeAsync(399)
|
||||
expect(state().refreshStatus).toBe('refreshing')
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(state().refreshStatus).toBe('idle')
|
||||
expect(state().packages[0]?.version).toBe('0.17.0')
|
||||
expect(state().status).toBe('ready')
|
||||
expect(state().notice).toBeNull()
|
||||
expect(inventory.list).toHaveBeenCalledTimes(2)
|
||||
expect(plugins.listBundles).toHaveBeenCalledTimes(2)
|
||||
expect(plugins.listPlugins).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it.each(['refused', 'rejected'] as const)('keeps cached packages ready after a %s refresh and clears its toast when retry starts', async (failure) => {
|
||||
vi.useFakeTimers()
|
||||
onTestFinished(() => { vi.useRealTimers() })
|
||||
const { inventory, plugins, face, state, controller } = bench()
|
||||
await controller.load()
|
||||
const packages = state().packages
|
||||
if (failure === 'refused') inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'offline'))
|
||||
else inventory.list.mockRejectedValueOnce(new Error('transport down'))
|
||||
face.refresh()
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
expect(state().refreshStatus).toBe('idle')
|
||||
expect(state()).toMatchObject({ status: 'ready', notice: { kind: 'refresh-failed', seq: 1 } })
|
||||
expect(state().packages).toBe(packages)
|
||||
const gate = deferred<ReturnType<typeof ok<BundleInfo[]>>>()
|
||||
plugins.listBundles.mockReturnValueOnce(gate.promise)
|
||||
face.refresh()
|
||||
expect(state()).toMatchObject({ status: 'ready', refreshStatus: 'refreshing', notice: null })
|
||||
expect(state().packages).toBe(packages)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(plugins.listBundles).toHaveBeenCalledTimes(2)
|
||||
gate.resolve(ok([BUNDLE]))
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
expect(state().refreshStatus).toBe('idle')
|
||||
expect(state()).toMatchObject({ status: 'ready', notice: null })
|
||||
inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'offline again'))
|
||||
face.refresh()
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
expect(state().notice).toEqual({ kind: 'refresh-failed', seq: 2 })
|
||||
face.dismissNotice()
|
||||
expect(state().notice).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['refused', 'rejected'] as const)('keeps a %s first refresh failure inline without a toast', async (failure) => {
|
||||
vi.useFakeTimers()
|
||||
onTestFinished(() => { vi.useRealTimers() })
|
||||
const inventory = failure === 'refused'
|
||||
? vi.fn().mockResolvedValueOnce(refused('gateway/internal', 'offline'))
|
||||
: vi.fn().mockRejectedValueOnce(new Error('transport down'))
|
||||
const { face, state } = bench({ inventory })
|
||||
face.refresh()
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
expect(state().refreshStatus).toBe('failed')
|
||||
expect(state()).toMatchObject({ status: 'error', packages: [], notice: null })
|
||||
})
|
||||
|
||||
it('treats an empty successful inventory as cached for refresh failures', async () => {
|
||||
vi.useFakeTimers()
|
||||
onTestFinished(() => { vi.useRealTimers() })
|
||||
const { inventory, face, state, controller } = bench({ listBundles: vi.fn().mockResolvedValue(ok([])) })
|
||||
await controller.load()
|
||||
expect(state().packages).toEqual([])
|
||||
inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'offline'))
|
||||
face.refresh()
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
expect(state().refreshStatus).toBe('idle')
|
||||
expect(state()).toMatchObject({ status: 'ready', packages: [], notice: { kind: 'refresh-failed' } })
|
||||
})
|
||||
|
||||
it('forgets cached inventory when the managed profile becomes unavailable', async () => {
|
||||
vi.useFakeTimers()
|
||||
onTestFinished(() => { vi.useRealTimers() })
|
||||
const { inventory, face, state, controller } = bench()
|
||||
await controller.load()
|
||||
inventory.list.mockResolvedValueOnce(ok({ entries: [], managementAvailable: false }))
|
||||
await controller.load()
|
||||
expect(state()).toMatchObject({ status: 'unavailable', packages: [] })
|
||||
inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'offline'))
|
||||
face.refresh()
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
expect(state().refreshStatus).toBe('failed')
|
||||
expect(state()).toMatchObject({ status: 'error', packages: [], notice: null })
|
||||
})
|
||||
|
||||
it('preserves unrelated notices when a manual refresh starts and succeeds', async () => {
|
||||
vi.useFakeTimers()
|
||||
onTestFinished(() => { vi.useRealTimers() })
|
||||
const { face, state, controller } = bench({
|
||||
setBundleEnabled: vi.fn().mockResolvedValue(ok({ ...APPLIED, application: 'restart-required' })),
|
||||
})
|
||||
await controller.load()
|
||||
face.setEnabled(BUNDLE.name, true)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(state().notice?.kind).toBe('restart')
|
||||
const notice = state().notice
|
||||
face.refresh()
|
||||
expect(state().notice).toBe(notice)
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
expect(state().refreshStatus).toBe('idle')
|
||||
expect(state().notice).toBe(notice)
|
||||
})
|
||||
|
||||
it('keeps background reads quiet and clears an uncached refresh failure when they succeed', async () => {
|
||||
vi.useFakeTimers()
|
||||
onTestFinished(() => { vi.useRealTimers() })
|
||||
const { plugins, face, state, controller } = bench({
|
||||
inventory: vi.fn().mockResolvedValueOnce(refused('gateway/internal', 'offline'))
|
||||
.mockResolvedValue(ok({ entries: [], managementAvailable: true })),
|
||||
})
|
||||
face.refresh()
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
expect(state().refreshStatus).toBe('failed')
|
||||
const gate = deferred<ReturnType<typeof ok<BundleInfo[]>>>()
|
||||
plugins.listBundles.mockReturnValueOnce(gate.promise)
|
||||
const loading = controller.load()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(plugins.listBundles).toHaveBeenCalledOnce()
|
||||
expect(state().refreshStatus).toBe('failed')
|
||||
gate.resolve(ok([BUNDLE]))
|
||||
await loading
|
||||
expect(state()).toMatchObject({ status: 'ready', refreshStatus: 'idle', notice: null })
|
||||
})
|
||||
|
||||
it.each(['background', 'manual'] as const)('holds the refresh indicator through coalesced reads when %s starts first', async (first) => {
|
||||
vi.useFakeTimers()
|
||||
onTestFinished(() => { vi.useRealTimers() })
|
||||
const { plugins, face, state, controller } = bench()
|
||||
await controller.load()
|
||||
const initial = deferred<ReturnType<typeof ok<BundleInfo[]>>>()
|
||||
const rerun = deferred<ReturnType<typeof ok<BundleInfo[]>>>()
|
||||
plugins.listBundles.mockReturnValueOnce(initial.promise).mockReturnValueOnce(rerun.promise)
|
||||
const background = first === 'background' ? controller.load() : undefined
|
||||
if (first === 'manual') face.refresh()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(plugins.listBundles).toHaveBeenCalledTimes(2)
|
||||
expect(state().refreshStatus).toBe(first === 'background' ? 'idle' : 'refreshing')
|
||||
const completion = background ?? controller.load()
|
||||
if (first === 'background') face.refresh()
|
||||
initial.resolve(ok([{ ...BUNDLE, version: '0.17.0' }]))
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
expect(plugins.listBundles).toHaveBeenCalledTimes(3)
|
||||
expect(state()).toMatchObject({ status: 'ready', refreshStatus: 'refreshing', notice: null })
|
||||
expect(state().packages[0]?.version).toBe('0.17.0')
|
||||
face.refresh()
|
||||
rerun.resolve(ok([{ ...BUNDLE, version: '0.18.0' }]))
|
||||
await completion
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(state().refreshStatus).toBe('idle')
|
||||
expect(state().packages[0]?.version).toBe('0.18.0')
|
||||
expect(state().notice).toBeNull()
|
||||
expect(plugins.listBundles).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it.each([false, true])('reports only the final coalesced refresh result (failure: %s)', async (failure) => {
|
||||
vi.useFakeTimers()
|
||||
onTestFinished(() => { vi.useRealTimers() })
|
||||
const { plugins, face, state, controller } = bench()
|
||||
await controller.load()
|
||||
const initial = deferred<ReturnType<typeof ok<BundleInfo[]>> | ReturnType<typeof refused>>()
|
||||
const rerun = deferred<ReturnType<typeof ok<BundleInfo[]>> | ReturnType<typeof refused>>()
|
||||
plugins.listBundles.mockReturnValueOnce(initial.promise).mockReturnValueOnce(rerun.promise)
|
||||
face.refresh()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(plugins.listBundles).toHaveBeenCalledTimes(2)
|
||||
const loading = controller.load()
|
||||
initial.resolve(failure ? ok([BUNDLE]) : refused('gateway/internal', 'offline'))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(plugins.listBundles).toHaveBeenCalledTimes(3)
|
||||
expect(state().refreshStatus).toBe('refreshing')
|
||||
expect(state().notice).toBeNull()
|
||||
rerun.resolve(failure ? refused('gateway/internal', 'offline') : ok([BUNDLE]))
|
||||
await loading
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
expect(state().refreshStatus).toBe('idle')
|
||||
expect(state().status).toBe('ready')
|
||||
expect(state().notice).toEqual(failure ? { kind: 'refresh-failed', seq: 1 } : null)
|
||||
})
|
||||
|
||||
it.each([false, true])('does not publish a manual refresh that settles after disposal (failure: %s)', async (failure) => {
|
||||
vi.useFakeTimers()
|
||||
onTestFinished(() => { vi.useRealTimers() })
|
||||
const { plugins, face, state, controller } = bench()
|
||||
await controller.load()
|
||||
const gate = deferred<ReturnType<typeof ok<BundleInfo[]>> | ReturnType<typeof refused>>()
|
||||
plugins.listBundles.mockReturnValueOnce(gate.promise)
|
||||
face.refresh()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(plugins.listBundles).toHaveBeenCalledTimes(2)
|
||||
const completion = controller.load()
|
||||
const before = state()
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = face.hooks.pluginManager.subscribe(listener)
|
||||
onTestFinished(unsubscribe)
|
||||
controller.dispose()
|
||||
gate.resolve(failure ? refused('gateway/internal', 'offline') : ok([{ ...BUNDLE, version: '0.17.0' }]))
|
||||
await completion
|
||||
face.refresh()
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
expect(state()).toBe(before)
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
expect(plugins.listBundles).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reports a Host without a managed profile as unavailable and keeps the last packages across a failed read', async () => {
|
||||
vi.useFakeTimers()
|
||||
onTestFinished(() => { vi.useRealTimers() })
|
||||
const { inventory, plugins, face, state, controller } = bench()
|
||||
await controller.load()
|
||||
expect(state().packages).toHaveLength(1)
|
||||
@@ -241,11 +465,12 @@ describe('PluginManagerController', () => {
|
||||
plugins.listPlugins.mockResolvedValueOnce(refused('gateway/internal', 'offline') as never)
|
||||
await controller.load()
|
||||
expect(state()).toMatchObject({ status: 'error', packages: [packageView(BUNDLE, PLUGINS)] })
|
||||
plugins.listBundles.mockResolvedValueOnce(refused('gateway/internal', 'offline') as never)
|
||||
plugins.listBundles.mockResolvedValueOnce(refused('gateway/internal', 'offline'))
|
||||
await controller.load()
|
||||
expect(state().status).toBe('error')
|
||||
face.refresh()
|
||||
await vi.waitFor(() => { expect(state().status).toBe('ready') })
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
expect(state().status).toBe('ready')
|
||||
})
|
||||
|
||||
it('enables a bundle, marks it busy meanwhile, and says when a restart is needed or a layer overrides it', async () => {
|
||||
@@ -1053,6 +1278,52 @@ describe('PluginManagerController', () => {
|
||||
await loading
|
||||
expect(state()).toBe(before)
|
||||
})
|
||||
|
||||
it.each([false, true])('drops refresh feedback when disposed during the spinner hold (failure: %s)', async (failure) => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { plugins, face, state, controller } = bench()
|
||||
await controller.load()
|
||||
plugins.listBundles.mockResolvedValueOnce(failure ? refused('gateway/internal', 'offline') : ok([BUNDLE]))
|
||||
face.refresh()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(state().refreshStatus).toBe('refreshing')
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
const before = state()
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = face.hooks.pluginManager.subscribe(listener)
|
||||
onTestFinished(unsubscribe)
|
||||
controller.dispose()
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
expect(state()).toBe(before)
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('settles a manual refresh whose read outlasts the spinner minimum without an extra hold', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { plugins, face, state, controller } = bench()
|
||||
await controller.load()
|
||||
const gate = deferred<ReturnType<typeof ok<BundleInfo[]>>>()
|
||||
plugins.listBundles.mockReturnValueOnce(gate.promise)
|
||||
face.refresh()
|
||||
await vi.advanceTimersByTimeAsync(401)
|
||||
expect(plugins.listBundles).toHaveBeenCalledTimes(2)
|
||||
expect(state().refreshStatus).toBe('refreshing')
|
||||
gate.resolve(ok([{ ...BUNDLE, version: '0.42.0' }]))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(state().refreshStatus).toBe('idle')
|
||||
expect(state().packages[0]?.version).toBe('0.42.0')
|
||||
expect(state().status).toBe('ready')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
it('offers the registries the Host configured, starts from its first, and asks the check and the run to start where the person chose', async () => {
|
||||
const gate = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
||||
const { plugins, face, state, controller, started } = bench({
|
||||
@@ -1156,15 +1427,18 @@ describe('PluginManagerController', () => {
|
||||
removeItem: (key: string) => { storage.delete(key) },
|
||||
})
|
||||
try {
|
||||
const { plugins, face, state } = bench({
|
||||
const { plugins, face, state, track } = bench({
|
||||
inspect: vi.fn(() => Promise.resolve(ok({ ...INSPECTED, registry: 'https://npm.corp.example/' }))),
|
||||
})
|
||||
face.openInstall()
|
||||
face.editInstallSpec('dsh-new')
|
||||
face.chooseRegistry({ kind: 'custom', url: ' npm.corp.example ' })
|
||||
face.runInstall()
|
||||
expect(state().install).toMatchObject({ phase: 'idle', registryError: true })
|
||||
expect(state().install).toMatchObject({ phase: 'idle', registryError: true, registryOpen: true })
|
||||
expect(plugins.inspect).not.toHaveBeenCalled()
|
||||
expect(track.mock.calls.filter(([event]) => event === 'install_plugin_result')).toEqual([
|
||||
['install_plugin_result', expect.objectContaining({ input_value: 'dsh-new', is_success: false, error_reason: 'invalid-registry' })],
|
||||
])
|
||||
// Typing again clears the refusal; a URL is asked as typed, trimmed.
|
||||
face.chooseRegistry({ kind: 'custom', url: ' https://npm.corp.example ' })
|
||||
expect(state().install.registryError).toBe(false)
|
||||
@@ -1172,6 +1446,9 @@ describe('PluginManagerController', () => {
|
||||
expect(plugins.inspect).toHaveBeenCalledWith('dsh-new', { registry: 'https://npm.corp.example' }, expect.any(AbortSignal))
|
||||
await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
|
||||
expect(plugins.installBundle).toHaveBeenCalledWith('dsh-new', expect.objectContaining({ registry: 'https://npm.corp.example/' }))
|
||||
const results = track.mock.calls.filter(([event]) => event === 'install_plugin_result')
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[1]).toEqual(['install_plugin_result', expect.objectContaining({ is_success: true, plugin_name: 'dsh-new' })])
|
||||
// A dialog opened later, by another controller, starts from the registry last used.
|
||||
const later = bench()
|
||||
later.face.openInstall()
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: ddec1f6f9b018747
|
||||
zh: 9555f132daf8901c
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
|
||||
en: 54ced5d959bd06c6
|
||||
zh: 5a7fc2504f92a788
|
||||
en: 27c3b3715866df73
|
||||
zh: 65c07b00516149ad
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
|
||||
en: 83052e14c3fb34cf
|
||||
zh: 0adb09885723296a
|
||||
|
||||
@@ -55,7 +55,7 @@ Check this table before writing a control in a feature package. A plugin cannot
|
||||
| `DisclosureRow` | 24px compact disclosure that lays title and content side by side. Memoized with shallow prop comparison; keep callbacks and React-node props stable when their content is unchanged. |
|
||||
| `Modal` | Centered dialog over a page mask. A nested dialog can intercept keys with `onKeyDownCapture` before document Escape handlers. The tint and dialog fade in while backdrop blur stays fully applied, honoring reduced motion. Set `backdropBlur={false}` when the caller already blurs the source page. |
|
||||
| `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. |
|
||||
| `Tooltip` | Hover text anchored to a cloned child; optional `portal` rendering escapes clipping containers and ancestor stacking contexts that cap the bubble's z-index. |
|
||||
| `Tooltip` | Hover and keyboard-focus text anchored to a cloned child; optional `delayMs` controls hover delay and `focusDelayMs` controls keyboard-focus delay, both defaulting to 0 ms. Optional `portal` rendering escapes clipping containers and ancestor stacking contexts that cap the bubble's z-index. |
|
||||
| `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. |
|
||||
| `ImageLightbox` | Shared image modal with focus restoration and Escape dismissal. |
|
||||
| `Toast` | Transient top-center banner held for the owner's `holdMs`. |
|
||||
|
||||
@@ -55,7 +55,7 @@ kind: "package-library"
|
||||
| `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。使用浅层 prop 比较进行 memo;内容未变时,保持回调与 React 节点 prop 的引用稳定。 |
|
||||
| `Modal` | 页面遮罩之上的居中对话框。嵌套对话框可通过 `onKeyDownCapture` 在文档级 Escape 处理器之前拦截按键。 色层与弹窗淡入,背景模糊始终完整生效,并遵循减少动态效果偏好。调用方已模糊源页面时设置 `backdropBlur={false}`。 |
|
||||
| `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 |
|
||||
| `Tooltip` | 锚定在克隆子元素上的悬停文本;可通过 `portal` 渲染到外层,避免被容器裁剪,或受祖先层叠上下文限制其 z-index。 |
|
||||
| `Tooltip` | 锚定在克隆子元素上的悬停与键盘聚焦提示;可选的 `delayMs` 控制悬停延迟,`focusDelayMs` 控制键盘聚焦延迟,均默认为 0 毫秒。可通过 `portal` 渲染到外层,避免被容器裁剪,或受祖先层叠上下文限制其 z-index。 |
|
||||
| `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 |
|
||||
| `ImageLightbox` | 共享图片浮层,支持焦点恢复与 Esc 关闭。 |
|
||||
| `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 |
|
||||
|
||||
@@ -40,7 +40,9 @@ type TooltipLabel = string | (() => string)
|
||||
* bubble would overlap); default 'center'. Ignored for side 'right'.
|
||||
* @param props.portal - render the bubble under document.body, so an ancestor's clipping or its
|
||||
* stacking context (which confines the bubble's z-index to that context) cannot hide it.
|
||||
* @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate.
|
||||
* @param props.delayMs - hover delay in milliseconds (default 0).
|
||||
* @param props.focusDelayMs - keyboard focus delay in milliseconds (default 0); blur, click,
|
||||
* mouse leave, disabling, and unmount cancel a pending show.
|
||||
* @param props.gap - anchor-to-bubble distance in pixels for 'bottom'/'top' bubbles (default 8);
|
||||
* ignored for side 'right'.
|
||||
* @param props.disabled - suppress the bubble while true; the anchor renders identically so
|
||||
@@ -53,7 +55,7 @@ type TooltipLabel = string | (() => string)
|
||||
* anchor dismisses the bubble until the next trigger, and focus arriving after a pointer
|
||||
* interaction (a closing menu refocusing its trigger) never raises it.
|
||||
*/
|
||||
export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center', delayMs = 0, gap = 8, disabled = false, portal = false, maxWidth, children }: { label: TooltipLabel; shortcutKeys?: readonly string[] | undefined; side?: TooltipSide; align?: 'center' | 'end'; delayMs?: number; gap?: number; disabled?: boolean; portal?: boolean; maxWidth?: number; children: ReactElement<AnchorProps> }) {
|
||||
export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center', delayMs = 0, focusDelayMs = 0, gap = 8, disabled = false, portal = false, maxWidth, children }: { label: TooltipLabel; shortcutKeys?: readonly string[] | undefined; side?: TooltipSide; align?: 'center' | 'end'; delayMs?: number; focusDelayMs?: number; gap?: number; disabled?: boolean; portal?: boolean; maxWidth?: number; children: ReactElement<AnchorProps> }) {
|
||||
const anchor = useRef<HTMLElement | null>(null)
|
||||
// React 18 keeps the element's ref outside props; forward it so wrapping an
|
||||
// anchor in Tooltip never silently severs the owner's ref.
|
||||
@@ -157,16 +159,16 @@ export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center',
|
||||
})
|
||||
announce(true)
|
||||
}
|
||||
const showAfterHoverDelay = () => {
|
||||
const showAfterDelay = (delay: number) => {
|
||||
cancelShow()
|
||||
if (delayMs <= 0) {
|
||||
if (delay <= 0) {
|
||||
show()
|
||||
return
|
||||
}
|
||||
showTimer.current = setTimeout(() => {
|
||||
showTimer.current = null
|
||||
show()
|
||||
}, delayMs)
|
||||
}, delay)
|
||||
}
|
||||
const withdraw = () => {
|
||||
setPos(null)
|
||||
@@ -198,13 +200,18 @@ export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center',
|
||||
<TooltipSuppression.Provider value={setSuppressed}>
|
||||
{cloneElement(children, {
|
||||
ref: mergedRef,
|
||||
onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; showAfterHoverDelay() },
|
||||
onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; showAfterDelay(delayMs) },
|
||||
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; cancelShow(); withdraw() },
|
||||
// Activating the anchor dismisses the bubble: the action often changes
|
||||
// what the anchor now does (pin → unpin), and the click leaves the
|
||||
// anchor focused, which would otherwise pin the relabelled bubble up.
|
||||
onClick: (e) => { children.props.onClick?.(e); triggers.current.focus = false; cancelShow(); withdraw() },
|
||||
onFocus: (e) => { children.props.onFocus?.(e); if (pointerModality()) return; triggers.current.focus = true; cancelShow(); show() },
|
||||
onFocus: (e) => {
|
||||
children.props.onFocus?.(e)
|
||||
if (pointerModality()) return
|
||||
triggers.current.focus = true
|
||||
showAfterDelay(focusDelayMs)
|
||||
},
|
||||
onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() },
|
||||
})}
|
||||
{portal ? (content !== false && createPortal(content, document.body)) : content}
|
||||
|
||||
@@ -59,6 +59,7 @@ export type {
|
||||
export { projectUserText, type UserTextReferences } from './user-text.tsx'
|
||||
export { Tooltip } from './Tooltip.tsx'
|
||||
export type { TooltipSide } from './Tooltip.tsx'
|
||||
export { pointerModality } from './input-modality.ts'
|
||||
export { Toast } from './Toast.tsx'
|
||||
export { fileSizeText } from './file-size.ts'
|
||||
export { writeClipboard } from './clipboard.ts'
|
||||
|
||||
@@ -70,13 +70,13 @@ export const PluginArtworkSearch = ({ size = 36, className }: IconProps) => {
|
||||
const uid = useArtworkId()
|
||||
return (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M26.5362 26.9865L22.3813 22.8317" stroke="#2F2295" strokeWidth="3" />
|
||||
<path d="M26.5362 26.9865L22.3813 22.8317" stroke="#658EFF" strokeWidth="3" />
|
||||
<g clipPath={`url(#${uid}ring)`}>
|
||||
<g transform="matrix(0.0119394 -0.00173904 0.00173904 0.0119394 15.7661 16.2159)">
|
||||
<foreignObject x="-958.94" y="-958.94" width="1917.88" height="1917.88">
|
||||
<div
|
||||
style={{
|
||||
background: 'conic-gradient(from 90deg, rgb(65, 225, 172) 0deg, rgb(85, 71, 210) 62.0619deg, rgb(65, 225, 172) 360deg)',
|
||||
background: 'conic-gradient(from 90deg, rgb(65, 225, 172) 0deg, rgb(101, 142, 255) 62.0619deg, rgb(65, 225, 172) 360deg)',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
|
||||
@@ -36,6 +36,20 @@ describe('plugin artwork', () => {
|
||||
expect(container.querySelector('foreignObject div')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('pairs the search green with the loop upper-right blue-violet in its ring and handle', () => {
|
||||
const { container } = render(<PluginArtworkSearch />)
|
||||
const ring = container.querySelector<HTMLElement>('foreignObject div')
|
||||
expect(ring?.style.background).toBe('conic-gradient(from 90deg, rgb(65, 225, 172) 0deg, rgb(101, 142, 255) 62.0619deg, rgb(65, 225, 172) 360deg)')
|
||||
expect(container.querySelector('path[stroke]')?.getAttribute('stroke')).toBe('#658EFF')
|
||||
})
|
||||
|
||||
it('uses light blue for both terminal strokes', () => {
|
||||
const { container } = render(<PluginArtworkTerminal />)
|
||||
const strokes = [...container.querySelectorAll('path')].map(path => path.getAttribute('stroke'))
|
||||
expect(strokes).toEqual(['#679EFE', '#679EFE'])
|
||||
expect(container.querySelector('linearGradient')).toBeNull()
|
||||
})
|
||||
|
||||
it.each(Object.entries(artworks))('%s renders at 36 by default and repeats without document-global ids', (_name, Artwork) => {
|
||||
const { container } = render(
|
||||
<>
|
||||
@@ -46,8 +60,8 @@ describe('plugin artwork', () => {
|
||||
for (const svg of container.querySelectorAll('svg')) expect(svg.getAttribute('width')).toBe('36')
|
||||
const ids = [...container.querySelectorAll('[id]')].map(node => node.id)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
for (const reference of container.querySelectorAll('[fill^="url("], [clip-path^="url("]')) {
|
||||
const target = (reference.getAttribute('fill') ?? reference.getAttribute('clip-path') ?? '').slice('url(#'.length, -1)
|
||||
for (const reference of container.querySelectorAll('[fill^="url("], [stroke^="url("], [clip-path^="url("]')) {
|
||||
const target = (reference.getAttribute('fill') ?? reference.getAttribute('stroke') ?? reference.getAttribute('clip-path') ?? '').slice('url(#'.length, -1)
|
||||
expect(ids).toContain(target)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -120,6 +120,85 @@ describe('Tooltip', () => {
|
||||
}
|
||||
})
|
||||
|
||||
describe('keyboard focus delay', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
fireEvent.keyDown(document.body, { key: 'Tab' })
|
||||
})
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
fireEvent.keyDown(document.body, { key: 'Tab' })
|
||||
})
|
||||
|
||||
it('shows at 500ms, but not at 499ms', () => {
|
||||
const label = vi.fn(() => 'Refresh')
|
||||
render(<Tooltip label={label} delayMs={500} focusDelayMs={500}><button>anchor</button></Tooltip>)
|
||||
fireEvent.focus(screen.getByText('anchor'))
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
act(() => { vi.advanceTimersByTime(499) })
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
expect(label).not.toHaveBeenCalled()
|
||||
act(() => { vi.advanceTimersByTime(1) })
|
||||
expect(screen.getByRole('tooltip').textContent).toBe('Refresh')
|
||||
})
|
||||
|
||||
it.each(['blur', 'click', 'mouseLeave', 'disabled', 'unmount'] as const)(
|
||||
'cancels a pending focus bubble on %s', (cancel) => {
|
||||
const view = render(<Tooltip label="Refresh" focusDelayMs={500}><button>anchor</button></Tooltip>)
|
||||
const anchor = screen.getByText('anchor')
|
||||
const initialTimers = vi.getTimerCount()
|
||||
fireEvent.focus(anchor)
|
||||
expect(vi.getTimerCount()).toBe(initialTimers + 1)
|
||||
act(() => { vi.advanceTimersByTime(499) })
|
||||
if (cancel === 'disabled') {
|
||||
view.rerender(<Tooltip label="Refresh" focusDelayMs={500} disabled><button>anchor</button></Tooltip>)
|
||||
} else if (cancel === 'unmount') {
|
||||
view.unmount()
|
||||
} else {
|
||||
fireEvent[cancel](anchor)
|
||||
}
|
||||
expect(vi.getTimerCount()).toBe(initialTimers)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
if (cancel === 'disabled') {
|
||||
view.rerender(<Tooltip label="Refresh" focusDelayMs={500}><button>anchor</button></Tooltip>)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps default focus immediate and cancels a pending hover delay', () => {
|
||||
render(<Tooltip label="Refresh" delayMs={500}><button>anchor</button></Tooltip>)
|
||||
const anchor = screen.getByText('anchor')
|
||||
const initialTimers = vi.getTimerCount()
|
||||
fireEvent.mouseEnter(anchor)
|
||||
act(() => { vi.advanceTimersByTime(499) })
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
fireEvent.focus(anchor)
|
||||
expect(screen.getByRole('tooltip').textContent).toBe('Refresh')
|
||||
expect(vi.getTimerCount()).toBe(initialTimers)
|
||||
})
|
||||
|
||||
it('does not schedule pointer-driven focus and resumes delay after a key press', () => {
|
||||
render(<Tooltip label="Refresh" focusDelayMs={500}><button>anchor</button></Tooltip>)
|
||||
const anchor = screen.getByText('anchor')
|
||||
const initialTimers = vi.getTimerCount()
|
||||
fireEvent.pointerDown(document.body)
|
||||
fireEvent.focus(anchor)
|
||||
expect(vi.getTimerCount()).toBe(initialTimers)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
fireEvent.blur(anchor)
|
||||
fireEvent.keyDown(document.body, { key: 'Tab' })
|
||||
fireEvent.focus(anchor)
|
||||
act(() => { vi.advanceTimersByTime(499) })
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
act(() => { vi.advanceTimersByTime(1) })
|
||||
expect(screen.getByRole('tooltip').textContent).toBe('Refresh')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the bubble to the right on hover and hides it on leave', () => {
|
||||
render(
|
||||
<Tooltip label="Open sidebar">
|
||||
|
||||
@@ -211,6 +211,7 @@ body {
|
||||
--dsw-alias-interactive-bg-hover-solid: var(--dsw-static-neutral-bluish-75);
|
||||
--dsw-alias-interactive-bg-hover: rgba(38, 49, 72, 0.06);
|
||||
--dsw-alias-label-caption: var(--dsw-static-neutral-bluish-400);
|
||||
--dsw-alias-label-deep-diving: color-mix(in srgb, var(--dsw-static-deepseek-500) 70%, var(--dsw-static-blue-950));
|
||||
--dsw-alias-label-dimmed: var(--dsw-static-neutral-bluish-200);
|
||||
--dsw-alias-label-primary-bluish: var(--dsw-static-blue-900);
|
||||
--dsw-alias-label-primary-dimmed: var(--dsw-static-neutral-bluish-950);
|
||||
@@ -321,6 +322,7 @@ body[data-ds-dark-theme] {
|
||||
--dsw-alias-interactive-bg-hover-solid: var(--dsw-static-neutral-bluish-800);
|
||||
--dsw-alias-interactive-bg-hover: rgba(255, 255, 255, 0.08);
|
||||
--dsw-alias-label-caption: var(--dsw-static-neutral-bluish-600);
|
||||
--dsw-alias-label-deep-diving: color-mix(in srgb, var(--dsw-static-deepseek-450) 55%, var(--dsw-static-neutral-bluish-400));
|
||||
--dsw-alias-label-dimmed: var(--dsw-static-neutral-bluish-750);
|
||||
--dsw-alias-label-primary-bluish: var(--dsw-static-neutral-bluish-50);
|
||||
--dsw-alias-label-primary-dimmed: var(--dsw-static-neutral-bluish-100);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 6L27.5 9.5V17.2C27.5 23.1 23.5 28.4 18 30C12.5 28.4 8.5 23.1 8.5 17.2V9.5L18 6Z" fill="url(#a)" />
|
||||
<path d="M13.5 18L16.6 21.1L22.9 14.8" stroke="#FFFFFF" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<g transform="translate(1.5 1.5) scale(0.9166666667)">
|
||||
<path d="M18 6L28.5 9.5V17.2C28.5 23.1 24.08 28.4 18 30C11.92 28.4 7.5 23.1 7.5 17.2V9.5L18 6Z" fill="url(#a)" fill-opacity="0.8" />
|
||||
<path d="M14.2 18L16.84 20.63L22.2 15.27" stroke="#FFFFFF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="a" x1="18" y1="6" x2="18" y2="30" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#7CB7FF" />
|
||||
<stop offset="1" stop-color="#145AF3" />
|
||||
<stop stop-color="#7AC2FF" />
|
||||
<stop offset="1" stop-color="#4A65E8" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 558 B After Width: | Height: | Size: 650 B |
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user