Merge branch 'master' into worktree/provider-credential-lifecycle
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.md
|
||||
2026-07-28-web-agent-runtime-context.md: 449c9d4ba2b144d02dee4b98ae80c86815aec5c1
|
||||
2026-07-28-web-agent-runtime-context.zh.md: def1674be5f193739bfb214a24f34590ee075d5f
|
||||
2026-07-28-web-agent-runtime-context.md: 6c2bb8faf0db56d2a29b47726ab295c3501cce2d
|
||||
2026-07-28-web-agent-runtime-context.zh.md: 09cbbdeabc244c265381b1ad67c3629b4c0763f5
|
||||
|
||||
@@ -10,13 +10,13 @@ The shared CLI base configured an empty deployment persona, the Web overlay did
|
||||
|
||||
## Decision
|
||||
|
||||
The shared Web/headless overlay (`apps/cli/config/web.cordis.yml`) supplies a concise coding-agent persona containing the resolved `{{model}}` and session `{{cwd}}`. `dsh web` additionally resolves the harness checkout from the launcher's module URL, installs the existing `harness:source` section, and adds an `app:web-surface` section before serving requests. The launcher registers that setup before mounting the config tree; its `systemPrompt` injection therefore installs both sections before later prompt consumers such as the agent loop can activate and emit a request header. The [source-checkout/workdir decision](2026-07-30-source-checkout-workdir-distinction.md) owns the source section's wording and its warning not to infer one path from the other.
|
||||
The Web profile composes the `dsh-base` and `dsh-web-app` bundles. The Web bundle supplies a concise coding-agent persona containing the resolved `{{model}}` and session `{{cwd}}`; its `web-runtime` plugin adds the `app:web-surface` section when `surfaceContext` is true. Before mounting the profile tree, the `dsh web` alias reads that same composed setting and installs the existing `harness:source` section only when the surface context is enabled. The headless bundle and a profile that owns its complete prompt set `surfaceContext: false`, suppressing the Web prompt and managed shell facts; the Web alias also suppresses the source section without checking an overlay path. Every mounted prompt contribution still activates before consumers such as the agent loop can emit a request header. The [source-checkout/workdir decision](2026-07-30-source-checkout-workdir-distinction.md) owns the source section's wording and its warning not to infer one path from the other.
|
||||
|
||||
The Web section treats unqualified references to “this page,” “this GUI,” or “this app” as references to the DeepSeek Harness Web GUI. It also states that the browser provides no implicit DOM, route, or screenshot context, so the model can identify the product without claiming visual state it did not receive. The assembled text is logged in `request/header`, preserving the model-visible/logged invariant.
|
||||
|
||||
## Verification
|
||||
|
||||
The focused startup-order test registers a later `systemPrompt` consumer and proves that it observes both launcher sections on its first activation. The keyless fresh-round-trip Web scenario boots the shipped base plus Web overlay, registers the same launcher context as `dsh web`, runs a real session through the HTTP/SSE application, and snapshots the first four system-prompt sections with source and working-directory paths normalized. The snapshot pins the harness identity, source checkout, Web orientation, and resolved coding-agent persona in request order.
|
||||
The Web runtime unit tests pin both enabled and disabled `surfaceContext` behavior, while the Web alias unit test pins default-on and explicit-off source-section gating from the composed row. The keyless fresh-round-trip Web scenario boots the shipped base plus Web bundle, runs a real session through the HTTP/SSE application, and snapshots the system-prompt prefix with source and working-directory paths normalized. The snapshot pins the harness identity, source checkout, Web orientation, and resolved coding-agent persona in request order. The Core Web snapshot applies the RL overlay and pins its complete system prompt with no source or Web section.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -30,4 +30,4 @@ The focused startup-order test registers a later `systemPrompt` consumer and pro
|
||||
|
||||
## Consequences
|
||||
|
||||
Web requests gain a short stable prompt prefix and may invalidate provider prefix caches once when this change is deployed. Agents can distinguish the GUI source checkout from the selected Workspace and resolve ordinary references to the current app without a clarification round trip. References to a specific visual state remain bounded by the explicit no-DOM/no-route/no-screenshot statement and may still require a path, description, or attachment.
|
||||
Ordinary Web requests gain a short stable prompt prefix and may invalidate provider prefix caches once when this change is deployed. Agents can distinguish the GUI source checkout from the selected Workspace and resolve ordinary references to the current app without a clarification round trip. References to a specific visual state remain bounded by the explicit no-DOM/no-route/no-screenshot statement and may still require a path, description, or attachment. Complete-prompt profiles can opt out through the Web runtime's composition setting without a launcher path check.
|
||||
|
||||
@@ -10,13 +10,13 @@ CLI 共享 base 配置了空的部署 persona,Web overlay 没有替换它,
|
||||
|
||||
## 决策
|
||||
|
||||
`apps/cli/config/web.cordis.yml` 这份 Web/无头共享 overlay 提供一段简洁的编码 agent persona,其中包含解析后的 `{{model}}` 与会话 `{{cwd}}`。`dsh web` 还会根据启动器模块的 URL 解析 harness checkout,安装现有的 `harness:source` 提示词段,并在对外提供请求服务前添加 `app:web-surface` 提示词段。启动器会在挂载配置树前注册这项设置;因此,它的 `systemPrompt` 注入会在 agent loop(智能体循环)等后续提示词消费方激活并发出 request header 之前安装这两个提示词段。源码提示词段的措辞,以及其中不得从一条路径推断另一条路径的警告,均由另行记录的[源码 checkout 与工作目录区分决策](2026-07-30-source-checkout-workdir-distinction.md)负责。
|
||||
Web profile 会组合 `dsh-base` 与 `dsh-web-app` 两个组合包。Web 组合包提供一段简洁的编码 agent persona,其中包含解析后的 `{{model}}` 与会话 `{{cwd}}`;当 `surfaceContext` 为 true 时,其 `web-runtime` 插件会添加 `app:web-surface` 提示词段。挂载 profile 配置树前,`dsh web` 别名会读取组合后的同一项设置,并且仅在启用表层上下文时安装现有的 `harness:source` 提示词段。无头组合包和拥有完整提示词的 profile 都会设置 `surfaceContext: false`,从而抑制 Web 提示词与受管 shell 事实;Web 别名也会抑制源码提示词段,而无需检查 overlay 路径。每项已挂载的提示词贡献仍会在 agent loop(智能体循环)等消费方发出 request header 前激活。源码提示词段的措辞,以及其中不得从一条路径推断另一条路径的警告,均由另行记录的[源码 checkout 与工作目录区分决策](2026-07-30-source-checkout-workdir-distinction.md)负责。
|
||||
|
||||
Web 提示词段把未限定的「这个页面」「这个 GUI」或「这个应用」解释为 DeepSeek Harness Web GUI。同时,它会明确说明浏览器不会隐式提供 DOM、路由或截图上下文,使模型能够识别产品,但不会声称掌握未收到的视觉状态。组装后的文本会记录在 `request/header` 中,从而保持「模型可见内容必须有日志记录」这一不变量。
|
||||
|
||||
## 验证
|
||||
|
||||
聚焦启动顺序的测试会注册一个后续的 `systemPrompt` 消费方,并证明该消费方首次激活时就能观察到启动器的两个提示词段。无密钥的 Web fresh-round-trip 场景会启动已交付的 base 与 Web overlay,注册与 `dsh web` 相同的启动器上下文,并通过 HTTP/SSE 应用运行一个真实会话。测试会把源码路径和工作目录规范化,然后对系统提示词的前四个段落生成快照。该快照按请求顺序固定 harness 身份、源码 checkout、Web 界面定位,以及解析后的编码 agent persona。
|
||||
Web 运行时单元测试会固定启用和禁用 `surfaceContext` 时的行为,Web 别名单元测试则会固定组合后配置行对源码提示词段的默认启用与显式禁用门控。无密钥的 Web fresh-round-trip 场景会启动已交付的 base 与 Web 组合包,通过 HTTP/SSE 应用运行一个真实会话,并在规范化源码路径与工作目录后对系统提示词前缀生成快照。该快照按请求顺序固定 harness 身份、源码 checkout、Web 界面定位,以及解析后的编码 agent persona。Core Web 快照会应用 RL overlay,并固定不含源码提示词段或 Web 提示词段的完整系统提示词。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -30,4 +30,4 @@ Web 提示词段把未限定的「这个页面」「这个 GUI」或「这个应
|
||||
|
||||
## 影响
|
||||
|
||||
Web 请求会增加一段较短且稳定的提示词前缀;部署此变更时,模型提供方的前缀缓存可能失效一次。agent 可以区分 GUI 源码 checkout 与所选 Workspace,并且无需再经过一轮澄清即可解析对当前应用的一般指代。对特定视觉状态的指代仍受「无 DOM/无路由/无截图」这一显式边界约束,必要时仍需用户提供路径、描述或附件。
|
||||
常规 Web 请求会增加一段较短且稳定的提示词前缀;部署此变更时,模型提供方的前缀缓存可能失效一次。agent 可以区分 GUI 源码 checkout 与所选 Workspace,并且无需再经过一轮澄清即可解析对当前应用的一般指代。对特定视觉状态的指代仍受「无 DOM/无路由/无截图」这一显式边界约束,必要时仍需用户提供路径、描述或附件。拥有完整提示词的 profile 可以通过 Web 运行时的组合设置选择退出,而无需检查启动器路径。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md
|
||||
2026-07-28-web-gui-feedback-loop.md: 039d2aebeeef903d10838a46b48e5172f0195126
|
||||
2026-07-28-web-gui-feedback-loop.zh.md: 34b6b26d4ce7c7e194e641536fffc503c013188b
|
||||
2026-07-28-web-gui-feedback-loop.md: aa488e1df087722c072d98c67d47cdf63a42f6b8
|
||||
2026-07-28-web-gui-feedback-loop.zh.md: d9de988e5a2cc57794ff628b65eb050dee610747
|
||||
|
||||
@@ -12,7 +12,7 @@ The [incident post-mortem](../../../../docs/postmortem/0003-web-agent-gui-feedba
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh web` publishes one canonical loopback URL and its actual runtime mode as both model-visible orientation and managed shell facts. The `app:web-surface` prompt section says that unqualified references identify this GUI and names the URL; `DSH_WEB_URL` and `DSH_WEB_MODE=production|development` carry the same facts into every foreground or managed background bash call. The section preserves the no-implicit-DOM, route, or screenshot boundary and does not claim that a LAN alias equals the browser's literal address.
|
||||
The ordinary `dsh web` composition mounts the Web bundle's `web-runtime` plugin, which publishes one canonical loopback URL and its actual runtime mode as both model-visible orientation and managed shell facts. The `app:web-surface` prompt section says that unqualified references identify this GUI and names the URL; `DSH_WEB_URL` and `DSH_WEB_MODE=production|development` carry the same facts into every foreground or managed background bash call. The section preserves the no-implicit-DOM, route, or screenshot boundary and does not claim that a LAN alias equals the browser's literal address. A complete-prompt profile sets the row's `surfaceContext` to false and receives neither the prompt section nor the managed variables; the Web launcher uses the same setting to suppress its source-checkout prompt section.
|
||||
|
||||
The mode-specific prompt makes the agent, rather than the user, own the hidden startup contract. Production mode defines acceptance as rebuilding the affected artifacts and refreshing the existing URL. Development mode states that `dsh web --dev` activates only the HMR receiver: automatic client-plugin reload additionally requires a same-checkout `pnpm run dev:web` watcher, which the agent verifies before promising no-refresh updates. Shell and other plain-package changes still require rebuild plus refresh. An agent in production mode explains both commands when a user requests no-refresh updates; it does not launch a replacement GUI unless asked.
|
||||
|
||||
@@ -36,4 +36,4 @@ The keyless fresh-round-trip browser scenario boots the shipped production Web c
|
||||
|
||||
## Consequences
|
||||
|
||||
Web prompts gain a dynamic URL-and-mode paragraph, so provider prefix reuse now varies by bound port and mode. Bash processes gain two non-secret managed environment variables. Bare Vite can no longer be used as a shell-only visual sandbox; developers use the full host or build mode instead. In exchange, GUI work has one mechanically observable target, the agent can teach the user the exact update behavior of the process actually serving their session, and the unsupported startup path fails before a white screen. The URL/mode contract guides the agent away from replacement ports; it does not prohibit arbitrary shell commands from starting one.
|
||||
Ordinary Web prompts gain a dynamic URL-and-mode paragraph, so provider prefix reuse now varies by bound port and mode. Their Bash processes gain two non-secret managed environment variables. Bare Vite can no longer be used as a shell-only visual sandbox; developers use the full host or build mode instead. In exchange, GUI work has one mechanically observable target, the agent can teach the user the exact update behavior of the process actually serving their session, and the unsupported startup path fails before a white screen. The URL/mode contract guides the agent away from replacement ports; it does not prohibit arbitrary shell commands from starting one. Profiles that disable `surfaceContext` also give up this feedback-loop guidance and shell context.
|
||||
|
||||
@@ -12,7 +12,7 @@ Web agent(智能体)既无法识别承载当前会话的 GUI,也不知道
|
||||
|
||||
## 决策
|
||||
|
||||
`dsh web` 发布一个规范的回环 URL 及其实际运行时模式,同时将二者作为模型可见的界面定位信息和受管 shell 事实。`app:web-surface` 提示词段说明:未加限定的指代指向此 GUI,并给出 URL;`DSH_WEB_URL` 和 `DSH_WEB_MODE=production|development` 会把同样的事实传入每次前台或受管后台 bash 调用。该段保留「不会隐式获得 DOM、路由或截图」这一边界,也不声称局域网别名等于浏览器中的实际地址。
|
||||
常规 `dsh web` 组合会挂载 Web 组合包的 `web-runtime` 插件,由它发布一个规范的回环 URL 及其实际运行时模式,同时将二者作为模型可见的界面定位信息和受管 shell 事实。`app:web-surface` 提示词段说明:未加限定的指代指向此 GUI,并给出 URL;`DSH_WEB_URL` 和 `DSH_WEB_MODE=production|development` 会把同样的事实传入每次前台或受管后台 bash 调用。该段保留「不会隐式获得 DOM、路由或截图」这一边界,也不声称局域网别名等于浏览器中的实际地址。拥有完整提示词的 profile 会把该配置行的 `surfaceContext` 设为 false,并且不会收到该提示词段和这些受管变量中的任何一个;Web 启动器也会使用同一项设置来抑制其源码 checkout 提示词段。
|
||||
|
||||
按模式区分的提示词让 agent 而非用户负责隐藏的启动契约。生产模式将验收定义为重新构建受影响的产物并刷新现有 URL。开发模式说明,`dsh web --dev` 只会启用 HMR(热模块替换)接收端:客户端插件要自动重新加载,还需要在同一检出中运行 `pnpm run dev:web` 监听进程,agent 会在承诺无需刷新即可更新前验证这一点。外壳和其他普通包的变更仍然需要重新构建并刷新。生产模式下的 agent 会在用户要求无需刷新即可更新时说明这两个命令;除非用户要求,否则不会启动替代 GUI。
|
||||
|
||||
@@ -36,4 +36,4 @@ Web agent(智能体)既无法识别承载当前会话的 GUI,也不知道
|
||||
|
||||
## 影响
|
||||
|
||||
Web 提示词会增加一个动态 URL 和模式段落,因此模型提供方的前缀复用会随绑定端口和模式变化。Bash 进程会增加两个非敏感的受管环境变量。裸 Vite 不再能用作只依赖 shell 的视觉沙箱;开发者应改用完整宿主或构建模式。作为交换,GUI 工作有了一个可由机制观察的唯一目标,agent 可以向用户说明实际承载其会话的进程究竟如何更新,不受支持的启动路径也会在出现白屏前失败。URL/模式契约会引导 agent 避免使用替代端口,但不会禁止任意 shell 命令启动替代服务。
|
||||
常规 Web 提示词会增加一个动态 URL 和模式段落,因此模型提供方的前缀复用会随绑定端口和模式变化。相应的 Bash 进程会增加两个非敏感的受管环境变量。裸 Vite 不再能用作只依赖 shell 的视觉沙箱;开发者应改用完整宿主或构建模式。作为交换,GUI 工作有了一个可由机制观察的唯一目标,agent 可以向用户说明实际承载其会话的进程究竟如何更新,不受支持的启动路径也会在出现白屏前失败。URL/模式契约会引导 agent 避免使用替代端口,但不会禁止任意 shell 命令启动替代服务。禁用 `surfaceContext` 的 profile 也会放弃这项反馈闭环指引与 shell 上下文。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md
|
||||
2026-07-29-sticky-composer-conversation-scroll.md: 69d46894a53b0113f3e4f0fe871bbf3f9697969b
|
||||
2026-07-29-sticky-composer-conversation-scroll.zh.md: c0d5a0640468207282316ecd2fa1f209708df7b5
|
||||
2026-07-29-sticky-composer-conversation-scroll.md: d3fed7a9d0b1f39f9551fbd85e0f83515b1a2690
|
||||
2026-07-29-sticky-composer-conversation-scroll.zh.md: 2beee34d3bb68832d14b7607b43aa11e1425d53d
|
||||
|
||||
@@ -10,7 +10,7 @@ The active conversation column split scrolling: the chat (and trajectory) view o
|
||||
|
||||
## Decision
|
||||
|
||||
While a session exists, `ConversationRoot` always supplies a `wrapActiveBody` owner callback that wraps the view ring in a `data-conversation-scroll` body and places a `data-composer-seat` around the whole `'conversation.composer'` chain output (fallback + elected overlay siblings from `overlay: true`). Active CSS sticks that seat with `position: sticky; bottom: 0` so Question/Approval takeovers stay visible when the user is not pinned to the floor; hero CSS centers the fallback stack inside the scroll body. `ConversationSession` keeps a chrome-hidden header + body shell while blank so that tree seat does not change on the first send. The session header remains `flex: none` column chrome above the scrollport when visible. ChatView and Trajectory/Waterfall keep a local scroller only when mounted outside that host (unit tests); under the host they set `overflow: visible` and resolve bottom-follow / prepend anchoring through `closest('[data-conversation-scroll]')`.
|
||||
`ConversationRoot` always owns one `data-conversation-scroll` body, with the strict `conversation.session` view outlet before a `data-composer-seat` around the whole `'conversation.composer'` chain output (fallback + elected overlay siblings from `overlay: true`). The separate strict `conversation.session.header` outlet remains `flex: none` column chrome above that scrollport and hides while the Session is blank. This fixed parent tree keeps the scroll body and composer seat mounted from no session through the blank Hero and active conversation. Active CSS sticks that seat with `position: sticky; bottom: 0` so Question/Approval takeovers stay visible when the user is not pinned to the floor; Hero CSS centers the fallback stack inside the scroll body. ChatView and Trajectory/Waterfall keep a local scroller only when mounted outside that host (unit tests); under the host they set `overflow: visible` and resolve bottom-follow / prepend anchoring through `closest('[data-conversation-scroll]')`.
|
||||
|
||||
Session stats live on `'conversation.composer.dock'` (above `'conversation.input.dock'`). The InputBar textarea, when inside the host, chains `wheel` with `{ passive: false }`: while the capped textarea can still scroll in that direction it keeps the native gesture; only at its own edge does it `preventDefault` and apply `deltaY` to the host.
|
||||
|
||||
@@ -22,7 +22,7 @@ Chat history prepend follows reader intent through stable rendered node/call ide
|
||||
|
||||
**Fixed flex-none composer below the scrollport with wheel forwarding.** Rejected: the product requires the composer to stick inside the transcript scrollport so the footer is part of that scroll hit-testing surface, not a sibling that only forwards deltas.
|
||||
|
||||
**Portal the composer into ChatView's scroller.** Rejected: the composer is shared across view tabs; the wrap target is the Session body owned by the resident shell.
|
||||
**Portal the composer into ChatView's scroller.** Rejected: the composer is shared across view tabs; its target is the root-owned scrollport in the resident shell.
|
||||
|
||||
**Keep StatsLine inside ChatView below the message column.** Rejected: outside the sticky composer it would scroll away while the input stayed pinned.
|
||||
|
||||
@@ -30,4 +30,4 @@ Chat history prepend follows reader intent through stable rendered node/call ide
|
||||
|
||||
## Consequences
|
||||
|
||||
Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Concurrent history, streaming, tool expansion, and composer reflow preserve wheel/trackpad scroll decisions, including Chromium's compositor-first delivery and stream-finalization clamp/regrow. Other browser scroll inputs do not change follow ownership under this narrow provenance rule. Hero → active keeps the same textarea DOM node (assembled slash-flow snapshot) and the InputHub draft.
|
||||
Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Concurrent history, streaming, tool expansion, and composer reflow preserve wheel/trackpad scroll decisions, including Chromium's compositor-first delivery and stream-finalization clamp/regrow. Other browser scroll inputs do not change follow ownership under this narrow provenance rule. No session → blank Hero and Hero → active both keep the same textarea DOM node and InputHub draft.
|
||||
|
||||
@@ -10,7 +10,7 @@ Status: implemented
|
||||
|
||||
## Decision
|
||||
|
||||
只要存在会话,`ConversationRoot` 就会始终提供 `wrapActiveBody` owner 回调,将视图环包进 `data-conversation-scroll` 主体,并用 `data-composer-seat` 包住整条 `'conversation.composer'` chain 输出(`overlay: true` 下的 fallback 与选举出的 overlay 兄弟节点)。活跃阶段 CSS 以 `position: sticky; bottom: 0` 钉住该 seat,使用户未贴底时 Question/Approval 接管仍可见;hero CSS 在滚动主体内居中 fallback 栈。`ConversationSession` 在 blank 时保留隐藏 chrome 的 header + body 壳,使首次发送时树座位不变。可见时会话标题栏仍是滚动容器之上的 `flex: none` 列 chrome。ChatView 与 Trajectory/Waterfall 仅在宿主之外挂载时(单元测试)保留本地 scroller;位于宿主下时设为 `overflow: visible`,并通过 `closest('[data-conversation-scroll]')` 解析贴底跟随与前置锚定。
|
||||
`ConversationRoot` 始终拥有同一个 `data-conversation-scroll` 主体,其中严格 `conversation.session` view outlet 位于 `data-composer-seat` 之前;该 seat 包住整条 `'conversation.composer'` chain 输出(`overlay: true` 下的 fallback 与选举出的 overlay 兄弟节点)。独立的严格 `conversation.session.header` outlet 作为 `flex: none` 列 chrome 位于滚动容器上方,并在 Session 仍为 blank 时隐藏。固定的父级树让滚动主体与 composer seat 从无 session、blank Hero 到活跃对话始终保持挂载。活跃阶段 CSS 以 `position: sticky; bottom: 0` 钉住该 seat,使用户未贴底时 Question/Approval 接管仍可见;Hero CSS 在滚动主体内居中 fallback 栈。ChatView 与 Trajectory/Waterfall 仅在宿主之外挂载时(单元测试)保留本地 scroller;位于宿主下时设为 `overflow: visible`,并通过 `closest('[data-conversation-scroll]')` 解析贴底跟随与前置锚定。
|
||||
|
||||
会话统计挂在 `'conversation.composer.dock'`(位于 `'conversation.input.dock'` 之上)。InputBar 的 textarea 在宿主内以 `{ passive: false }` 链式处理 `wheel`:在限高 textarea 仍能沿该方向滚动时保留原生手势;仅在自身边缘才 `preventDefault` 并将 `deltaY` 施加到宿主。
|
||||
|
||||
@@ -22,7 +22,7 @@ Chat 历史前插通过稳定的已渲染 node/call 身份跟随读者意图
|
||||
|
||||
**滚动容器下方 flex-none 固定编辑器并转发滚轮。** 否决:产品要求编辑器 sticky 在 transcript 滚动容器内,使页脚成为该滚动命中面的一部分,而不是仅转发增量的兄弟节点。
|
||||
|
||||
**把编辑器 portal 进 ChatView 的 scroller。** 否决:编辑器跨视图标签共享;包装目标是常驻壳拥有的 Session 主体。
|
||||
**把编辑器 portal 进 ChatView 的 scroller。** 否决:编辑器跨视图标签共享;其目标是常驻壳中由 root 持有的滚动容器。
|
||||
|
||||
**把 StatsLine 留在 ChatView 消息列下方。** 否决:落在 sticky 编辑器之外会随内容滚走,而输入区仍钉在底部。
|
||||
|
||||
@@ -30,4 +30,4 @@ Chat 历史前插通过稳定的已渲染 node/call 身份跟随读者意图
|
||||
|
||||
## Consequences
|
||||
|
||||
在页脚上滚轮会滚动 transcript;可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。并发历史加载、流式输出、工具展开与编辑器重排会保留滚轮/触控板的滚动决定,包括 Chromium 先推进合成器几何状态再交付事件,以及流收尾阶段滚动位置受钳制后滚动容器重新增长的情况。在这条窄范围的输入来源规则下,其他浏览器滚动输入不会改变贴底跟随所有权。hero → active 保持同一 textarea DOM 节点(assembled slash-flow 快照)以及 InputHub 草稿。
|
||||
在页脚上滚轮会滚动 transcript;可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。并发历史加载、流式输出、工具展开与编辑器重排会保留滚轮/触控板的滚动决定,包括 Chromium 先推进合成器几何状态再交付事件,以及流收尾阶段滚动位置受钳制后滚动容器重新增长的情况。在这条窄范围的输入来源规则下,其他浏览器滚动输入不会改变贴底跟随所有权。无 session → blank Hero 与 Hero → active 都保持同一 textarea DOM 节点以及 InputHub 草稿。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md
|
||||
2026-07-31-hero-visible-while-blank-session-opens.md: b39963beffa403ef6fa44735aa88395a99139751
|
||||
2026-07-31-hero-visible-while-blank-session-opens.zh.md: b451d7c00ec7eb8d736134e738d72e5e07fd1b04
|
||||
2026-07-31-hero-visible-while-blank-session-opens.md: 6afa5d0ee2b695d6805d20f54e82073db8028df7
|
||||
2026-07-31-hero-visible-while-blank-session-opens.zh.md: f21e549b5811d81094374b1363186a1d18fbadaf
|
||||
|
||||
@@ -24,12 +24,10 @@ The summary flag and the snapshot's own `blank` are distinct sources: the snapsh
|
||||
|
||||
## Deferred
|
||||
|
||||
The no-session→session tree relocation in `ConversationRoot` (the hero/composer subtree moves into the `conversation.session` outlet) still rebuilds the composer DOM on the same transition; removing it means moving `conversation.session` to `session-maybe` scope, a slot-contract change that needs its own proposal.
|
||||
|
||||
Object-layer reference churn found while diagnosing this — no-op projections minting fresh snapshots, the create path projecting twice, `select()` using `notifyNow` from async continuations — is real but independent of the visible flash.
|
||||
|
||||
## Consequences
|
||||
|
||||
Startup auto-selection renders the hero immediately and keeps the composer seat and header visible through the history round-trip, so launching into a recent workspace no longer looks like a page reload. Sessions whose summary does not prove them blank keep the previous settling behavior, so the guard still covers the case it was written for. Skeleton tests pin all three summary shapes: a row reporting `blank: false` settles, an absent row settles, and a summary-proven blank session opening under `loading` renders hero chrome with a live textarea.
|
||||
|
||||
The assembled coverage is `apps/web/tests/startup-auto-selection.e2e.ts` (keyless web browser lane): it registers a workspace, holds the `session.history` response open at the browser's network boundary, and asserts the visible frame while the auto-selected open is in flight — hero phase, hero title, painted composer — plus a recorded phase timeline of exactly `['hero']` for the whole load. Holding the round-trip is what makes it a regression test rather than a race: against a loopback host the open settles too fast to sample, and with the exemption reverted the held window is precisely when the root reports `settling`.
|
||||
The assembled coverage is `apps/web/tests/startup-auto-selection.e2e.ts` (keyless web browser lane). Its first Workspace connection asserts that the Hero root, Workspace chip, scroll body, composer seat, and textarea remain the same DOM nodes when the blank Session appears. It then holds the `session.history` response open at the browser's network boundary and asserts the visible frame while the auto-selected open is in flight — hero phase, hero title, painted composer — plus a recorded phase timeline of exactly `['hero']` for the whole load. Holding the round-trip is what makes the second case a regression test rather than a race: against a loopback host the open settles too fast to sample, and with the exemption reverted the held window is precisely when the root reports `settling`.
|
||||
|
||||
@@ -24,12 +24,10 @@ Status: implemented
|
||||
|
||||
## 推迟事项
|
||||
|
||||
`ConversationRoot` 中"无会话→有会话"的树位置迁移(hero/composer 子树移入 `conversation.session` 出口)仍会在同一次转换中重建 composer 的 DOM;消除它意味着把 `conversation.session` 移到 `session-maybe` 作用域,这是一次插槽契约变更,需要单独立项。
|
||||
|
||||
诊断期间发现的对象层引用抖动——空操作投影铸造出新的快照、创建路径重复投影一次、`select()` 在异步续体中使用 `notifyNow`——确实存在,但与这次可见闪烁相互独立。
|
||||
|
||||
## 影响
|
||||
|
||||
启动自动选择会立即渲染 hero,并在整个历史往返期间保持 composer 座位与 header 可见,因此启动进入最近工作区不再像页面重载。摘要未证明为空白的会话保持原有的 settling 行为,这道防护仍覆盖它当初针对的场景。骨架测试固定了摘要的三种形态:报告 `blank: false` 的行进入 settling;根本没有该行同样进入 settling;摘要已证明为空白的会话在 `loading` 期间渲染 hero 外壳与可用的文本框。
|
||||
|
||||
组装级覆盖是 `apps/web/tests/startup-auto-selection.e2e.ts`(无密钥的 Web 浏览器泳道):它注册一个工作区,在浏览器网络边界上扣住 `session.history` 的响应,并在自动选择的打开仍在飞行途中断言可见画面——hero 阶段、hero 标题、已绘制的 composer——外加整次加载记录到的阶段时间线恰好为 `['hero']`。扣住这次往返正是它成为回归测试而非竞态的原因:对着回环主机,打开会快到无从采样;而一旦回退这条豁免,被扣住的这段窗口恰恰就是根节点报告 `settling` 的时刻。
|
||||
组装级覆盖是 `apps/web/tests/startup-auto-selection.e2e.ts`(无密钥的 Web 浏览器泳道)。首次连接 Workspace 时,它断言 blank Session 出现前后 Hero root、Workspace chip、滚动主体、composer seat 与 textarea 都是同一 DOM 节点。随后它在浏览器网络边界上扣住 `session.history` 的响应,并在自动选择的打开仍在飞行途中断言可见画面——hero 阶段、hero 标题、已绘制的 composer——外加整次加载记录到的阶段时间线恰好为 `['hero']`。扣住这次往返正是第二个用例成为回归测试而非竞态的原因:对着回环主机,打开会快到无从采样;而一旦回退这条豁免,被扣住的这段窗口恰恰就是根节点报告 `settling` 的时刻。
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md
|
||||
2026-08-04-conversation-column-one-axis-scroll.md: 9a487c506a75033d0854f08e95da24704309003d
|
||||
2026-08-04-conversation-column-one-axis-scroll.zh.md: 23441a7c8655d1f19d3c0fe0f661f81f69b55dba
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: The conversation column scrolls on one axis
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-04-conversation-column-one-axis-scroll.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Narrowing the center column — by the window or by the sidebar drag — put a horizontal scrollbar under the whole conversation column on the hero. The bleeding element is the hero's decorative backdrop ellipse: `.heroGlow` is sized `1051/776` of the hero box so its blur scales in userSpace with the input card, which means it reaches past the column whenever the column is narrower than the glow.
|
||||
|
||||
That bleed is by construction and stays. What made it user-visible is the scroll container it sits in. `[data-conversation-scroll]` declared `overflow-y: auto` and left the other axis at its initial `visible`, and a box that scrolls in one axis computes `visible` to `auto` in the other. Every column narrower than the glow therefore offered a real horizontal scroll range — measured at 24–95px across the widths a laptop actually produces.
|
||||
|
||||
## Decision
|
||||
|
||||
`.scrollBody` declares `overflow-x: hidden`. The column states that it is a one-axis scroller instead of leaving the second axis to be derived.
|
||||
|
||||
Clipping does not change. `overflow-y: auto` had already made the box a scroll container that clips both axes, so the declaration withdraws only the scrollbar and the user gesture; the glow keeps its bleed, its blur radius, and the same painted extent, and the column keeps its vertical scroll. Nothing in the composer chain moves.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Size the glow to fit the column.** Rejected. The glow's width is what scales its `stdDeviation="50"` blur with the input card (figma 313:14109); constraining it would make the blur tighten as the column narrows, which is a visual regression to fix a scrollbar.
|
||||
|
||||
**Wrap the glow in a clipping box.** Rejected. It adds a box whose only job is to undo an overflow the column already clips, and it leaves the derived `overflow-x: auto` in place for the next element that bleeds — the transcript is full of candidates.
|
||||
|
||||
**Rely on the frame's `.centerCol { overflow: hidden }`.** It cannot help. That clip is outside the scroll container, so it hides the glow's overhang at the column border while the container inside it still scrolls to reach it. The reported bar was that container's.
|
||||
|
||||
**Assert `scrollWidth === clientWidth` in the test.** Rejected as the signal, because it does not distinguish the states: `hidden` clips the bleed rather than reflowing it away, so the scroll range reads the same on both sides of the fix. Only refusing a user gesture differs, which is what the scenario measures.
|
||||
|
||||
## Testing
|
||||
|
||||
[apps/web/tests/conversation-column-overflow.e2e.ts](../../../../apps/web/tests/conversation-column-overflow.e2e.ts) sweeps viewport widths bracketing the glow and, at each stop, wheels horizontally over the column and reads `scrollLeft`. The committed golden records the relation per stop; the widest stop is the control where the glow does not bleed at all.
|
||||
|
||||
Two guards keep the scenario honest. The vacuity guard asserts the glow still reaches past the column at the narrow stops, so the claim cannot pass by the symptom having disappeared for an unrelated reason. The mutation control forces `overflow-x: auto` back on in the page and shows the same gesture, at the same timing, carrying the column to its positive scroll boundary; the test measures that boundary directly because a stable scrollbar gutter can leave some overflow on the negative side of the scroll origin. Without the control, a `scrollLeft` of 0 could equally mean the wheel never arrived.
|
||||
|
||||
## Consequences
|
||||
|
||||
The conversation column no longer offers a horizontal scrollbar at any width, and decorative bleed in the composer chain is now clipped rather than exposed as scroll range. The cost is that genuinely wide content under this column is clipped instead of reachable by scrolling: any such surface owns its own scroller, as the markdown code block and the trajectory table already do.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note:会话列只在一个轴上滚动
|
||||
|
||||
状态:已实现
|
||||
|
||||
[English](2026-08-04-conversation-column-one-axis-scroll.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
当中间列被拉窄——无论是拖窗口还是拖侧边栏——hero 态的整条会话列下方就会出现一条横向滚动条。溢出的元素是 hero 的装饰性背景椭圆:`.heroGlow` 的宽度取 hero 盒子的 `1051/776`,好让它的模糊在 userSpace 中随输入卡片一同缩放;这也意味着只要列比它窄,它就会伸出列外。
|
||||
|
||||
这处外溢是设计使然,保持不变。真正让它对用户可见的是它所处的滚动容器。`[data-conversation-scroll]` 只声明了 `overflow-y: auto`,另一个轴留在初始值 `visible`;而一个在某一轴上滚动的盒子,会把另一轴的 `visible` 计算为 `auto`。于是每一条比该椭圆窄的列都真的给出了一段横向滚动范围——在笔记本实际会产生的几档宽度上,实测为 24–95px。
|
||||
|
||||
## 决定
|
||||
|
||||
`.scrollBody` 声明 `overflow-x: hidden`。这条列明确声明自己是单轴滚动容器,而不是把第二个轴交给推导。
|
||||
|
||||
裁剪行为不变。`overflow-y: auto` 早已使该盒子成为在两个轴上都裁剪的滚动容器,因此这条声明收回的只是滚动条和用户手势;椭圆保留它的外溢、模糊半径和同样的绘制范围,列也保留纵向滚动。输入区那条链路上没有任何东西移动。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**把椭圆缩到列内。** 否决。椭圆的宽度正是让它 `stdDeviation="50"` 的模糊随输入卡片缩放的依据(figma 313:14109);约束宽度会使列越窄模糊越紧,等于为修一条滚动条而制造一处视觉回归。
|
||||
|
||||
**给椭圆套一层裁剪盒。** 否决。这层盒子唯一的职责是抵消列本就会裁剪的溢出,而推导出的 `overflow-x: auto` 仍然留在原处,等着下一个外溢的元素——会话流里这样的候选者不少。
|
||||
|
||||
**依赖外框的 `.centerCol { overflow: hidden }`。** 它帮不上忙。那处裁剪在滚动容器之外,只能在列边界处遮住椭圆探出的部分,而里面的容器照样可以滚过去够到它。用户报告的那条滚动条属于内层容器。
|
||||
|
||||
**在测试里断言 `scrollWidth === clientWidth`。** 作为判据被否决,因为它区分不出两种状态:`hidden` 裁剪外溢,而不是把它重排掉,所以修复前后读到的滚动范围一样。唯一有差别的是拒绝用户手势,这正是该场景所测量的。
|
||||
|
||||
## 测试
|
||||
|
||||
[apps/web/tests/conversation-column-overflow.e2e.ts](../../../../apps/web/tests/conversation-column-overflow.e2e.ts) 扫过一组把椭圆宽度夹在中间的视口宽度,在每一档上向列横向滚轮并读取 `scrollLeft`。提交的 golden 逐档记录该关系;最宽的一档是椭圆根本不外溢的对照。
|
||||
|
||||
两道防线保证该场景不流于形式。空断言防线断言窄档上椭圆确实仍伸出列外,使这项主张不可能因为症状出于无关原因消失而通过。变异对照则在页面内把 `overflow-x: auto` 强制改回,证明同一手势在同一时序下能把列带到正向滚动边界。测试直接测量该边界,因为稳定的滚动条槽可能让部分外溢处于滚动原点的负向。没有这项对照,`scrollLeft` 读到 0 同样可以解释为滚轮根本没送达。
|
||||
|
||||
## 后果
|
||||
|
||||
会话列在任何宽度下都不再给出横向滚动条,输入区链路上的装饰性外溢从暴露为滚动范围改为被裁剪。代价是这条列下真正过宽的内容会被裁掉而非可滚动够到:这类界面各自拥有自己的滚动容器,markdown 代码块和轨迹表格已经如此。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.md
|
||||
2026-08-06-onboarding-step-owned-takeover-chrome.md: 4b3bbbc03c4494359297ae6e54bcc9a74c387e80
|
||||
2026-08-06-onboarding-step-owned-takeover-chrome.zh.md: 548285d285939325a26df3b8d70c43fe952813a9
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: onboarding takeover chrome moves into the step
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-06-onboarding-step-owned-takeover-chrome.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The settings shell mounted the onboarding takeover chrome — a body-portaled overlay with an opaque `--dsw-alias-bg-layer-1` stage, a blur mask, and `#root` set inert — the moment a `settings.onboarding` step was registered and not yet locally completed. Every step decides whether it actually needs to show by loading a private fact first (WelcomeNotice: the acknowledgement bit through its settings join; DeepSeekOnboardingDialog: credential readiness through the Models join) and renders `null` while that fact is in flight. Rendering `null` could not suppress the chrome, because the opaque stage was painted by the shell around the slot outlet, not by the step.
|
||||
|
||||
On every reload while the hero (blank or no session) was current, the sessions list turning `ready` therefore popped a full-screen opaque layer — white in the light palette — and blocked all interaction for exactly one credential/settings RPC round-trip, after which the already-configured steps self-completed and the layer vanished. Users saw the app flash white each refresh the moment the workspace/session lists landed.
|
||||
|
||||
## Decision
|
||||
|
||||
The takeover chrome belongs to the step, not the shell. A new zero-cordis primitive, `OnboardingSurface` (ui-primitives), renders the body-portaled overlay/mask/stage — CSS class names and geometry moved verbatim from `SettingsRoot.module.css` — and holds `#root` inert for exactly its own mount lifetime. Both step components wrap only their **visible** branch in it; their existing `null` branches now paint and block nothing by construction, because the chrome is part of the same render decision.
|
||||
|
||||
`SettingsRoot` keeps the coordinator exactly as it was (ordered ledger projection, one mounted step, local completed set, `stepId`/`complete`/`openSection` currency) but renders the elected step bare — no portal, no stage, no inert effect. The `settings.onboarding` slot contract now states that registrants own the surface wrap and must render `null` while their private facts are undecided.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Register steps conditionally (ledger as the has-content signal).** Register the entry only after the private join resolves to "needs intervention". Architecturally clean (publish at the commit point) but a larger change: the join load must move from the dialogs into each plugin's apply, and registration/disposal becomes reactive plumbing in two packages. Rejected as oversized for the defect.
|
||||
|
||||
**Convert `settings.onboarding` to a chain with an externalized completed-set store.** The composer-takeover pattern; prototyped and reverted. Selectors can only judge owner props, so the private readiness facts still had to be resolved inside the components — the chain bought routing generality the two current steps do not need, at the cost of a contract change across three packages.
|
||||
|
||||
**Detect empty slot output at the render site.** `renderSlot` returns an outlet element unconditionally, so the owner cannot branch on a step's `null`; probing rendered DOM emptiness needs a commit-then-retract dance whose dynamic transitions lose the pre-paint guarantee.
|
||||
|
||||
## Consequences
|
||||
|
||||
While a step is mounted but undecided, the application stays visible and interactive: `#root` is no longer inert during the decision window (previously it was inert behind an opaque layer). For a genuinely unconfigured user the takeover now appears one join round-trip later than before — but with its content already present, instead of an empty stage that fills in.
|
||||
|
||||
A future step that registers without wrapping its visible content in `OnboardingSurface` renders bare over the app with no mask; the slot contract JSDoc names the wrap as the registrant's obligation.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/client/ui-primitives/tests/onboarding-surface.spec.tsx` pins the primitive: body portal around the content, mask/stage class presence, `#root` inert held for exactly the mount lifetime, and the no-`#root` composition. `packages/client/ui-settings/tests/settings-root.spec.tsx` pins the inverted shell contract: no takeover chrome and no inert while a mounted step renders nothing. `apps/web/tests/onboarding-deepseek-config.e2e.ts` gains the defect's assembled regression pin: a configured world reloads while every `settings.describe` response is held open at the browser's network boundary — widening the steps' deciding window from loopback-invisible to hundreds of milliseconds, which is what keeps the assertions non-vacuous — and an 8 ms in-page sampler proves the takeover chrome never mounts and `#root` never turns inert. The file's existing scenarios and the step specs (`ui-settings-general`, `ui-models`) pass unchanged — the mask selector and geometry pins survive because the stylesheet moved verbatim.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note:首次使用引导的接管界面框架移入步骤自身
|
||||
|
||||
状态:已实现
|
||||
|
||||
[English](2026-08-06-onboarding-step-owned-takeover-chrome.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
设置外壳在 `settings.onboarding` 有已注册且本地未完成的步骤时,就立即挂出首次使用引导的接管界面框架——portal 到 body 的浮层,带不透明的 `--dsw-alias-bg-layer-1` 展示层、模糊遮罩,并把 `#root` 置为 `inert`。而每个步骤都要先加载私有事实才能判定自己是否需要出场(WelcomeNotice:经其设置 join 读取确认位;DeepSeekOnboardingDialog:经 Models join 读取凭据就绪状态),判定期间渲染 `null`。渲染 `null` 无法抑制界面框架,因为不透明展示层是外壳画在 slot outlet 外面的,不属于步骤。
|
||||
|
||||
于是每次在 hero(空白或无会话)状态下刷新页面,会话列表一变 `ready` 就弹出整屏不透明层——亮色主题下是白色——并阻断全部交互,时长恰好等于一次凭据/设置 RPC 往返;之后已配置好的步骤自我完成,图层消失。用户看到的就是每次刷新在 workspace/会话列表落地的瞬间闪一下白屏。
|
||||
|
||||
## 决定
|
||||
|
||||
接管界面框架属于步骤,不属于外壳。新增零 cordis 原语 `OnboardingSurface`(ui-primitives):渲染 portal 到 body 的浮层/遮罩/展示层——CSS 类名与几何从 `SettingsRoot.module.css` 逐字迁移——并在自身挂载生命周期内保持 `#root` 为 `inert`。两个步骤组件只把各自的**可见**分支包进该原语;既有的 `null` 分支由此在构造上不绘制、不阻塞任何内容,因为界面框架已是同一次渲染决策的一部分。
|
||||
|
||||
`SettingsRoot` 的协调器原样保留(有序账本投影、每次挂载一个步骤、本地完成集合、`stepId`/`complete`/`openSection` currency),但对当选步骤裸渲染——不再有 portal、展示层和 inert 效果。`settings.onboarding` 的 slot 契约现在写明:注册方持有外层包裹,且在私有事实未决时必须渲染 `null`。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**条件注册(账本即有内容信号)。** 私有 join 解析出「需要介入」后才注册条目。架构上干净(在 commit point 发布),但改动更大:join 的加载必须从对话框上移到各插件的 apply,注册/销毁在两个包里都变成响应式接线。对本缺陷而言过重,否决。
|
||||
|
||||
**把 `settings.onboarding` 改成 chain 并把完成集合外置为 store。** composer takeover 的版型;做过原型后回退。selector 只能判定 owner props,私有就绪事实仍然只能在组件内部解析——chain 买来的是当前两个步骤并不需要的路由通用性,代价却是跨三个包的契约变更。
|
||||
|
||||
**在渲染点探测 slot 输出为空。** `renderSlot` 无条件返回 outlet 元素,owner 无法据步骤的 `null` 分支;探测已渲染 DOM 是否为空需要先提交再撤回的手法,其动态翻转会失去 paint 前的保证。
|
||||
|
||||
## 后果
|
||||
|
||||
步骤已挂载但尚未判定期间,应用保持可见且可交互:判定窗口内 `#root` 不再是 `inert`(此前是在不透明图层背后被置灰)。对真正未配置的用户,接管层比从前晚一个 join 往返出现——但一出现就带着内容,而不是先露出空白展示层再填充。
|
||||
|
||||
未来若有步骤注册后不把可见内容包进 `OnboardingSurface`,会无遮罩地裸渲染在应用之上;slot 契约的 JSDoc 已把包裹写为注册方的义务。
|
||||
|
||||
## 测试
|
||||
|
||||
`packages/client/ui-primitives/tests/onboarding-surface.spec.tsx` 钉住原语行为:内容外的 body portal、遮罩/展示层类名存在、`#root` 的 `inert` 恰好持续挂载生命周期,以及无 `#root` 的组合。`packages/client/ui-settings/tests/settings-root.spec.tsx` 钉住反转后的外壳契约:已挂载步骤什么都不渲染时,无接管界面框架、无 inert。`apps/web/tests/onboarding-deepseek-config.e2e.ts` 新增本缺陷的整装回归钉:已配置世界刷新页面,同时在浏览器网络边界扣住所有 `settings.describe` 响应——把步骤的判定窗口从 loopback 下不可见拉宽到数百毫秒,这正是断言保持非空洞的关键——页内 8ms 采样器证明接管界面框架从未挂载、`#root` 从未变为 inert。该文件的既有场景与步骤 spec(`ui-settings-general`、`ui-models`)原样通过——样式表逐字迁移,遮罩选择器与几何钉子得以幸存。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.md
|
||||
2026-08-06-token-surface-unpriced-replace-compatibility.md: 77b778fba695f560eb68af6da2f416e61ccff181
|
||||
2026-08-06-token-surface-unpriced-replace-compatibility.zh.md: dd4c5883c4c6d37809c2f8100f266655d8ba600c
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: unpriced surface replacements fold neutrally
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-06-token-surface-unpriced-replace-compatibility.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The `contextPressure` and `contextBreakdown` projections keep a running surface-token total plus at most one pending shadow-price claim, so their persisted checkpoints stay O(1) over a session's life. Current replace producers append a `compact/summary` or `compact/prune` metering event immediately before the replacement; its `shadowedTokenCount` prices the exact replaced range, and `foldSurfaceProjection` turns that into the signed delta.
|
||||
|
||||
Sessions recorded before the shadow-price protocol log replacements with no adjacent metering event. The O(1) state cannot reconstruct the replaced range's price, and the fold treated every unpriced replacement as a contract violation and threw — so replaying such a session died at its first replacement (`token surface: replace at seq … has no adjacent shadow price`), leaving the session permanently unopenable.
|
||||
|
||||
## Decision
|
||||
|
||||
A replace that arrives with no armed claim folds price-neutrally: `foldSurfaceProjection` returns `deltaTokens: 0`, pricing the replaced range as if it had cost exactly what its replacement costs, and replay continues. A claim expired by an intervening event reaches the same neutral path, since the fold cannot distinguish it from a log that never metered.
|
||||
|
||||
An armed claim naming a **different** range still throws. There the metering event was adjacent, so the producer wrote contradictory adjacent events — a live shadow-price contract violation, not historical data, and it must fail loud rather than let the total drift silently.
|
||||
|
||||
Both projections share the one fold, so neither gains state fields nor bumps its `stateVersion`. `surface-fold.ts` and `ctx.tokenMeter.measure()` are unaffected: they hold the per-node priced surface and never needed the claim protocol.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep throwing.** Preserves the strict producer contract, but every pre-protocol session stays permanently unreplayable, and the projections exist to serve replay.
|
||||
|
||||
**Persist the full priced surface in the projection state.** Could price any replaced range exactly, but grows the checkpoint by one node per model-visible message without bound — defeating the O(1) constraint the shadow-price protocol exists to preserve (see [the context-meter note](2026-08-05-context-meter-blind-to-compaction.md)).
|
||||
|
||||
## Consequences
|
||||
|
||||
An unpriced replacement holds the total still instead of shrinking it, so the compacted-away span stays counted: `contextBreakdown.messageTokens` retains the overcount, and `contextPressure.projectedTokens` overestimates occupancy only until the next usage sample re-anchors it, because that figure tracks movement since the sample rather than the absolute level. The error direction is safe — overestimating occupancy at worst invites an earlier compaction.
|
||||
|
||||
The loud failure survives where it still means something: a range-mismatched adjacent claim is a current producer bug and still throws.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/llm/token-meter/tests/context-breakdown-projection.spec.ts` pins the neutral fold for the no-claim and expired-claim replacements, the throw for a mismatched claim, and the exact pricing for a matched one. `packages/llm/token-meter/tests/token-usage-projection.spec.ts` pins `contextPressure` holding still across an unpriced replacement.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: 未计价的表层替换以中性方式折叠
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-06-token-surface-unpriced-replace-compatibility.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`contextPressure` 与 `contextBreakdown` 两个投影只维护一份滚动累计的表层 token 总量,外加至多一条待结算的影子价格(shadow price)声明,因此其持久化检查点在会话整个生命周期内保持 O(1)。当前的替换生产方会紧贴在替换之前追加一条 `compact/summary` 或 `compact/prune` 计量事件;其 `shadowedTokenCount` 对被替换区间精确计价,`foldSurfaceProjection` 再把它换算成有符号增量。
|
||||
|
||||
影子价格协议引入之前录制的会话,其日志中的替换没有相邻的计量事件。O(1) 状态无法重建被替换区间的价格,而折叠此前把每一次未计价替换都当作契约违规并抛出异常,于是回放这类会话会在第一处替换就中断(`token surface: replace at seq … has no adjacent shadow price`),会话从此永远无法打开。
|
||||
|
||||
## 决策
|
||||
|
||||
到达时没有已就位声明的替换以价格中性的方式折叠:`foldSurfaceProjection` 返回 `deltaTokens: 0`,相当于把被替换区间计价为恰好等于其替换内容的成本,回放随即继续。因中间插入的事件而过期的声明也走同一条中性路径,因为折叠无法把它与从未计量过的日志区分开。
|
||||
|
||||
已就位但指向**另一个**区间的声明仍会抛出异常。此时计量事件确实相邻,说明生产方写入了互相矛盾的相邻事件:这是现行影子价格契约的违规,不是历史数据,必须响亮失败,而不能任由总量悄然漂移。
|
||||
|
||||
两个投影共用同一个折叠,因此二者都不新增状态字段,也不提升 `stateVersion`。`surface-fold.ts` 与 `ctx.tokenMeter.measure()` 不受影响:它们持有逐节点的已计价表层,本来就不需要声明协议。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**维持抛出异常。**保住了严格的生产方契约,但协议之前的每个会话都将永远无法回放,而投影本就是为服务回放而存在的。
|
||||
|
||||
**在投影状态中持久化完整的已计价表层。**可以对任意被替换区间精确计价,但检查点会随每条模型可见消息各增加一个节点、无上限地增长,恰恰破坏了影子价格协议所要守住的 O(1) 约束(见[上下文仪表的 Agent Note](2026-08-05-context-meter-blind-to-compaction.md))。
|
||||
|
||||
## 影响
|
||||
|
||||
未计价的替换让总量保持不动而不是缩小,因此被压缩(compaction)掉的区段仍被计入:`contextBreakdown.messageTokens` 保留这部分多计的量;`contextPressure.projectedTokens` 会高估占用率,但只持续到下一个用量样本重新锚定为止,因为该数字追踪的是自样本以来的增减,而非绝对水平。误差方向是安全的:高估占用率最坏不过是招致一次更早的压缩。
|
||||
|
||||
响亮失败保留在它仍有意义的地方:区间不匹配的相邻声明是现行生产方的缺陷,仍会抛出异常。
|
||||
|
||||
## 测试
|
||||
|
||||
`packages/llm/token-meter/tests/context-breakdown-projection.spec.ts` 钉住了无声明与声明过期两种替换的中性折叠、声明区间不匹配时的抛出异常,以及声明匹配时的精确计价。`packages/llm/token-meter/tests/token-usage-projection.spec.ts` 钉住了 `contextPressure` 在一次未计价替换前后保持不动。
|
||||
Reference in New Issue
Block a user