From abf498b8dc12b973ff698ff1ef3191f36bad0fc4 Mon Sep 17 00:00:00 2001 From: kingwl Date: Thu, 30 Jul 2026 17:26:44 +0800 Subject: [PATCH 1/5] fix(web): align composer context stack --- ...-30-composer-context-stack-order.i18n.yaml | 6 +++ ...2026-07-30-composer-context-stack-order.md | 33 +++++++++++++++ ...6-07-30-composer-context-stack-order.zh.md | 33 +++++++++++++++ .../2026-07-22-docked-web-goal-bar.i18n.yaml | 4 +- .../feature/2026-07-22-docked-web-goal-bar.md | 8 ++-- .../2026-07-22-docked-web-goal-bar.zh.md | 8 ++-- ...-composer-stats-and-input-polish.i18n.yaml | 4 +- ...-30-web-composer-stats-and-input-polish.md | 10 ++--- ...-web-composer-stats-and-input-polish.zh.md | 10 ++--- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/queue/QueueDock.module.css | 8 ++-- .../src/client/queue/QueueDock.tsx | 4 +- .../skeleton/ConversationRoot.module.css | 11 ++--- .../src/client/skeleton/TodoPanel.module.css | 15 +++---- .../src/client/skeleton/TodoPanel.tsx | 4 +- .../ui-conversation/tests/queue-dock.spec.tsx | 9 +++- .../ui-conversation/tests/todo-panel.spec.tsx | 4 +- packages/client/ui-goal/README.i18n.yaml | 4 +- packages/client/ui-goal/README.md | 2 +- packages/client/ui-goal/README.zh.md | 2 +- .../ui-goal/src/client/GoalBar.module.css | 41 ++++++++----------- packages/client/ui-goal/src/client/index.ts | 2 +- .../ui-goal/tests/browser-plugin.spec.tsx | 2 +- 25 files changed, 152 insertions(+), 80 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.i18n.yaml new file mode 100644 index 0000000000..a6db678efa --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.md +2026-07-30-composer-context-stack-order.md: 9c269bcaf7fa360b6a5d0e16fd8cda48aa285d66 +2026-07-30-composer-context-stack-order.zh.md: 47288141ea4591b29adde0f85e810fc797740488 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.md b/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.md new file mode 100644 index 0000000000..9c269bcaf7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.md @@ -0,0 +1,33 @@ +# Agent Note: Composer context stack order + +Status: implemented + +English | [中文](2026-07-30-composer-context-stack-order.zh.md) + +## Problem + +Goal, Todo, and Queue contribute independently to the same `conversation.input.dock` list, but their registration order and spacing rules did not encode the composition matrix. The renderer therefore placed Todo before Queue and Goal, while both Queue and Goal carried negative margins intended for the composer boundary. When all three were present, Queue joined to Goal and Goal joined to the composer, reversing the design's hierarchy. + +## Decision + +The composer context stack has one canonical ascending order: Goal at `0`, Todo at `10`, and Queue at `20`, followed by the composer bar outside the list. The gaps leave room for future entries to declare their intended position without relying on plugin activation order. + +`ConversationRoot` owns the 6px space between independent context cards. Goal is a standalone 752×36px card and collapsed Todo is a standalone 752×44px card. Queue is the terminal dock entry: its 776px wrapper contains the same 752px panel column and subtracts the shared gap plus a named 5px layout overlap, so the later composer card paints over only the queue edge. Empty entries render null and consume no gap. + +The order and overlap are separate contracts. Registration order establishes semantic hierarchy; CSS variables on the stack establish shared geometry. Queue does not infer that it may overlap merely from being the last visible entry, because Goal or Todo can be the last visible context card when no queue exists and must remain separated from the composer. + +## Verification + +Registration tests pin all three order values. Browser screenshots cover the full Goal/Todo/Queue matrix, Goal+Todo without Queue, and Queue alone; together they exercise every adjacency: Goal–Todo, Todo–Queue, and Queue–Composer. + +## Alternatives considered + +**Keep independent negative margins on Goal and Queue.** Rejected because the affected neighbor changes with slot order; a local margin cannot express which relationship is allowed unless the semantic order is also fixed. + +**Render each known dock id separately in `ConversationRoot`.** Rejected because it turns an extensible list slot into a hardcoded component inventory and forces the owner to change for every new registrant. + +**Tuck whichever dock entry is last.** Rejected because Goal and Todo are standalone cards. Their absence matrix must not change the surface semantics of whichever card remains. + +## Consequences + +The visual hierarchy is stable for every presence combination, and Queue is the only context surface joined to the composer. New input-dock plugins must choose an order relative to Goal `0`, Todo `10`, and Queue `20`; an entry after Queue also requires an explicit decision about which surface owns the composer boundary. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.zh.md new file mode 100644 index 0000000000..47288141ea --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Composer 上下文堆栈顺序 + +Status: implemented + +[English](2026-07-30-composer-context-stack-order.md) | 中文 + +## 问题 + +Goal、Todo 与 Queue 独立注册到同一个 `conversation.input.dock` 列表,但各自的注册顺序与间距规则没有编码组合矩阵。因此,渲染器将 Todo 放在 Queue 和 Goal 之前,而 Queue 与 Goal 都带有用于 composer 边界的负外边距。三者同时出现时,Queue 与 Goal 相接,Goal 与 composer 相接,颠倒了设计层级。 + +## 决策 + +composer 上下文堆栈采用唯一规范的升序排列:Goal 为 `0`,Todo 为 `10`,Queue 为 `20`,随后是位于列表外的 composer bar。顺序值之间的空档使未来条目可以声明预期位置,不必依赖插件激活顺序。 + +`ConversationRoot` 负责独立上下文卡片之间的 6px 间距。Goal 是一张独立的 752×36px 卡片,折叠后的 Todo 是一张独立的 752×44px 卡片。Queue 是末端 dock 条目:其 776px 包装层包含相同的 752px 面板列,并减去共享间距与具名的 5px 布局重叠量,因此后渲染的 composer 卡片只覆盖 Queue 边缘。空条目渲染为 null,不占用间距。 + +顺序与重叠是两项独立契约。注册顺序定义语义层级,stack 上的 CSS 变量定义共享几何。系统不能仅因 Queue 是最后一个可见条目,就推断它可以与 composer 重叠,因为没有 Queue 时,Goal 或 Todo 可能成为最后一个可见上下文卡片,而它们必须与 composer 保持间隔。 + +## 验证 + +注册测试固定了三个顺序值。浏览器截图覆盖完整的 Goal/Todo/Queue 组合矩阵、没有 Queue 的 Goal+Todo,以及仅有 Queue 的情况;这些场景共同覆盖全部相邻关系:Goal–Todo、Todo–Queue 与 Queue–Composer。 + +## 考虑过的替代方案 + +**Goal 和 Queue 分别保留独立的负外边距。** 不予采纳,因为受影响的相邻项会随 slot 顺序变化;除非语义顺序也固定,否则局部外边距无法表达允许哪一种关系。 + +**在 `ConversationRoot` 中分别渲染每个已知 dock id。** 不予采纳,因为这会把可扩展的列表 slot 变成硬编码的组件清单,并迫使 owner 在每新增一个注册方时随之修改。 + +**让最后一个 dock 条目贴卡。** 不予采纳,因为 Goal 和 Todo 是独立卡片;Goal 或 Todo 缺席时的组合不得改变剩余卡片的界面语义。 + +## 后果 + +所有存在组合下的视觉层级都保持稳定,Queue 是唯一与 composer 相接的上下文界面。新的 input-dock 插件必须相对于 Goal `0`、Todo `10` 与 Queue `20` 选择顺序;若条目位于 Queue 之后,还必须明确决定由哪个界面负责 composer 边界。 diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml index 187c4dfe94..176211d25b 100644 --- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md -2026-07-22-docked-web-goal-bar.md: 110aea299a260896b0098f10b337734e0c0aebcf -2026-07-22-docked-web-goal-bar.zh.md: cc0a5eda6815e6c02e97fd02197659764d4f2d69 +2026-07-22-docked-web-goal-bar.md: f014da61d2fa0bf25121c040dae99354ab15de9d +2026-07-22-docked-web-goal-bar.zh.md: f62c6efbb2d330fb7d5ab74138eb781f1a1bc06c diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md index 110aea299a..f014da61d2 100644 --- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md +++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md @@ -6,11 +6,11 @@ English | [中文](2026-07-22-docked-web-goal-bar.zh.md) ## Problem -The web UI had no goal surface at all: the goal stack shipped with model tools, the TUI/ACP adapters, and the `/goal` command, but the browser client exposed none of it — no runtime verbs, no indicator. This change introduces the client goal verbs (runtime session methods over RPC) and the first goal UI together. Placement follows the redesign's premise that goal presence belongs to the composer's context: the goal is a property of the work the user is about to prompt, so its indicator docks directly above the message composer as a rounded-top strip tucked under the composer card's top edge. The mock keeps only a sparkle, a phase word ("Ongoing/Paused/Blocked Goal"), the truncated objective, and edit/clear icon actions, with resume appearing only on a paused goal. +The web UI had no goal surface at all: the goal stack shipped with model tools, the TUI/ACP adapters, and the `/goal` command, but the browser client exposed none of it — no runtime verbs, no indicator. This change introduces the client goal verbs (runtime session methods over RPC) and the first goal UI together. Placement follows the redesign's premise that goal presence belongs to the composer's context: the goal is a property of the work the user is about to prompt, so its indicator belongs in the composer-context stack; the [composer context stack decision](../bug-fix/2026-07-30-composer-context-stack-order.md) owns its position among Goal, Todo, Queue, and the composer. The mock keeps only a sparkle, a phase word ("Ongoing/Paused/Blocked Goal"), the truncated objective, and edit/clear icon actions, with resume appearing only on a paused goal. ## Decision -`GoalBar` (`packages/client/ui-goal/src/client/GoalBar.tsx`) is a new props-driven, self-contained component; `ConversationRoot` mounts it immediately before the composer `InputBar`. The strip's CSS mirrors the composer's horizontal geometry (32px side padding, 776px centered cap) plus the mock's 12px inset, and a -10px bottom margin eats InputBar's 8px top padding and tucks its square bottom edge 2px under the composer card's top edge. All strip states share one fixed 38px height so switching between them never resizes it. Loading (`goal === undefined`), absent (`goal === null`), and `phase === 'complete'` render nothing — a completed goal is history, not chrome. +`GoalBar` (`packages/client/ui-goal/src/client/GoalBar.tsx`) is a props-driven, self-contained component registered first in the composer's input-dock list. Its standalone 752px card follows the composer's horizontal geometry, and every visible state shares one fixed 36px height so switching phases never resizes it. Loading (`goal === undefined`), absent (`goal === null`), and `phase === 'complete'` render nothing — a completed goal is history, not chrome. Visibility drives the label and actions: active shows "Ongoing Goal" with pause/edit/clear; paused shows "Paused Goal" and swaps pause for a resume icon button; blocked shows "Blocked Goal" and carries `blockedReason.message` as the strip's `title` tooltip. Goal creation lives on the `/goal` command, not in the bar. The pencil swaps the strip for an inline edit form prefilled with the current objective: Enter or the check button saves through `GoalBarActions.onEdit(objective)`, Esc cancels, and an all-whitespace objective keeps save disabled. The form closes only when the edit succeeds; a failure preserves the draft and displays the error in the bar. Resume and clear failures are displayed there as well. Clear otherwise calls `onClear` directly with no confirmation — a clear keeps a durable tombstone, so nothing is unrecoverable. An effect keyed on the goal's id drops the edit form when the goal's identity changes, so a surviving draft can never be written over the goal that replaced it. @@ -26,14 +26,14 @@ The strip's background is `--dsw-alias-interactive-bg-hover` rather than the moc ## Alternatives considered -- **Put the strip in the session header** — rejected because the redesign's premise is that goal presence belongs to the composer's context; a header strip cannot dock into the composer card. +- **Put the strip in the session header** — rejected because the redesign's premise is that goal presence belongs to the composer's context; a header strip separates it from Todo, Queue, and the prompt it qualifies. - **Render a "Loading goal…" placeholder for `undefined`** — rejected: the strip would flash and collapse on every session open, chrome noise for a sub-second state. - **Include an inline create affordance when no goal is set** — rejected after implementation review: goal creation lives on the `/goal` command, matching the pattern where the model creates goals on request; the bar is a status indicator, not a creation surface. - **Carry the full verb set (`onComplete` included) in `GoalBarActions`** — rejected as speculative generality: the interface carries only the rendered verbs (`onPause` joined it when the active strip gained its pause action). ## Consequences -- Goal presence in the web UI is a composer-docked strip: sparkle, phase label, truncated objective, and pause/edit/clear (resume replacing pause when paused) — the browser client's first goal surface. +- Goal presence in the web UI is a standalone composer-context strip: sparkle, phase label, truncated objective, and pause/edit/clear (resume replacing pause when paused) — the browser client's first goal surface. - The runtime session exposes the goal verbs over RPC with folded transport errors, and refreshes the snapshot's goal on open and on live goal-change meta (coalesced, guarded against stale reads). - Objective editing is reachable from the UI for the first time, through `goal.edit` with the runtime-owned ref; complete remains available to other surfaces (`/goal`, model tools). - `goal === null` renders nothing; the composer carries no persistent create affordance — creation is the `/goal` command's job. diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md index cc0a5eda68..f62c6efbb2 100644 --- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md @@ -6,11 +6,11 @@ Status: implemented ## 问题 -Web UI 此前没有任何目标相关的界面:目标栈已随模型工具、TUI/ACP 适配器和 `/goal` 命令交付,但浏览器客户端完全不接触它——既没有运行时动词,也没有指示器。本变更同时引入客户端目标动词(基于 RPC 的运行时会话方法)和第一个目标 UI。摆放位置遵循重新设计的前提:目标的存在感属于输入框的上下文——目标是用户即将提交的工作的属性,因此它的指示器停靠在消息输入框正上方,呈现为一条圆角顶部的横条,收进输入框卡片顶边之下。设计稿只保留一个闪光图标、一个阶段词("Ongoing/Paused/Blocked Goal")、截断后的目标内容,以及编辑/清除图标操作,恢复按钮仅在目标暂停时出现。 +Web UI 此前没有任何目标相关的界面:目标栈已随模型工具、TUI/ACP 适配器和 `/goal` 命令交付,但浏览器客户端完全不接触它——既没有运行时动词,也没有指示器。本变更同时引入客户端目标动词(基于 RPC 的运行时会话方法)和第一个目标 UI。摆放位置遵循重新设计的前提:目标的存在感属于输入框的上下文——目标是用户即将提交的工作的属性,因此它的指示器属于 composer 上下文堆栈;[composer 上下文堆栈决策](../bug-fix/2026-07-30-composer-context-stack-order.md) 规定它在 Goal、Todo、Queue 与 composer 之间的位置。设计稿只保留一个闪光图标、一个阶段词("Ongoing/Paused/Blocked Goal")、截断后的目标内容,以及编辑/清除图标操作,恢复按钮仅在目标暂停时出现。 ## 决策 -`GoalBar`(`packages/client/ui-goal/src/client/GoalBar.tsx`)是一个新的、由 props 驱动的自包含组件;`ConversationRoot` 将它挂载在输入框 `InputBar` 紧上方。横条的 CSS 对齐输入框的水平几何(两侧 32px 内边距、776px 居中上限),再加上设计稿的 12px 内缩,并用 -10px 的下外边距吃掉 InputBar 的 8px 上内边距,使它方形的底边收进输入框卡片顶边之下 2px。横条的所有状态共享固定的 38px 高度,状态切换不会引起尺寸变化。加载中(`goal === undefined`)、无目标(`goal === null`)和 `phase === 'complete'` 时不渲染任何内容:已完成的目标是历史记录,不是常驻界面元素。 +`GoalBar`(`packages/client/ui-goal/src/client/GoalBar.tsx`)是一个由 props 驱动的自包含组件,在 composer 的 input-dock 列表中注册为第一个条目。它采用独立的 752px 卡片,遵循 composer 的水平几何;所有可见状态均使用固定的 36px 高度,切换阶段不会改变尺寸。加载中(`goal === undefined`)、无目标(`goal === null`)和 `phase === 'complete'` 时不渲染任何内容:已完成的目标是历史记录,不是常驻界面元素。 可见性决定标签和操作:active 状态显示 "Ongoing Goal" 并提供暂停/编辑/清除;paused 状态显示 "Paused Goal",把暂停换成一个恢复图标按钮;blocked 状态显示 "Blocked Goal",并把 `blockedReason.message` 作为横条的 `title` 悬浮提示。创建目标的入口在 `/goal` 命令上,不在横条里。铅笔图标把横条切换为内联编辑表单,预填当前目标内容:Enter 或勾选按钮通过 `GoalBarActions.onEdit(objective)` 保存,Esc 取消,目标内容全为空白字符时保存按钮保持禁用。编辑成功后表单才会关闭;编辑失败时保留草稿,并在横条中显示错误。恢复和清除失败也显示在横条中。除此之外,清除直接调用 `onClear`,不做确认——清除会保留 durable 墓碑,没有不可恢复的损失。一个以目标 id 为键的 effect 会在目标身份变化时丢弃编辑表单,因此存留的草稿绝不可能覆盖掉替换它的新目标。 @@ -26,14 +26,14 @@ Web UI 此前没有任何目标相关的界面:目标栈已随模型工具、T ## 考虑过的替代方案 -- **把横条放在会话头部**:不予采纳,因为重新设计的前提是目标的存在感属于输入框的上下文;放在头部的横条无法停靠进输入框卡片。 +- **把横条放在会话头部**:不予采纳,因为重新设计的前提是目标的存在感属于输入框的上下文;头部横条会使目标与 Todo、Queue 及其限定的提示词彼此分离。 - **为 `undefined` 渲染 "Loading goal…" 占位**:不予采纳,每次打开会话横条都会闪现再坍缩,对一个不到一秒的状态来说只是界面噪音。 - **未设置目标时在横条内提供内联创建入口**:实现评审后不予采纳,创建目标的职责在 `/goal` 命令上,与模型按请求创建目标的模式一致;横条是状态指示器,不是创建入口。 - **在 `GoalBarActions` 中携带完整动词集合(含 `onComplete`)**:作为投机性泛化不予采纳,接口只携带实际渲染的动词(active 横条获得暂停操作后,`onPause` 随之加入)。 ## 后果 -- Web UI 中目标的存在形式是停靠在输入框上方的横条:闪光图标、阶段标签、截断的目标内容,以及暂停/编辑/清除(暂停时恢复取代暂停)——这是浏览器客户端的第一个目标界面。 +- Web UI 中目标的存在形式是独立的 composer 上下文横条:闪光图标、阶段标签、截断的目标内容,以及暂停/编辑/清除(暂停时恢复取代暂停)——这是浏览器客户端的第一个目标界面。 - 运行时会话通过 RPC 暴露目标动词并折叠传输层错误,且在打开时和 live 目标变更元数据到达时刷新快照中的目标(合并拉取,带陈旧读取守卫)。 - 目标内容首次可以从 UI 编辑,经由 `goal.edit`,ref 由运行时持有;完成对其他界面(`/goal`、模型工具)照常可用。 - `goal === null` 时不渲染任何内容;输入框不提供常驻的创建入口,创建是 `/goal` 命令的职责。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml index c82bd65908..653b7c0554 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md -2026-07-30-web-composer-stats-and-input-polish.md: 0d90b8c1d2e283f2bcca7d9e82ac461d9fa4eb7e -2026-07-30-web-composer-stats-and-input-polish.zh.md: db47250852724e62337948aa516effb42f19066c +2026-07-30-web-composer-stats-and-input-polish.md: 78f286cb0edf58d0212492024b8706ffd432ee70 +2026-07-30-web-composer-stats-and-input-polish.zh.md: eeba56d9f3c2ef10222e32b0809e099f60181ac8 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md index 0d90b8c1d2..78f286cb0e 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md @@ -10,19 +10,19 @@ The web composer footer showed a single joined stats string (cache/tokens/turns/ ## Decision -**The stats line renders inside the InputBar's width column through a new `footer` owner prop and expands to the design's grouped detail row; the composer stack owns one 8px rhythm; the seat fades the transcript through a fixed 36px token-bound gradient; the back-to-bottom control follows a live `--dsh-composer-height`; goal and todo share one 752px tip-fill column.** +**The stats line renders inside the InputBar's width column through a new `footer` owner prop and expands to the design's grouped detail row; the composer stack owns one 6px rhythm; the seat fades the transcript through a fixed 36px token-bound gradient; the back-to-bottom control follows a live `--dsh-composer-height`; goal, todo, and queue share one 752px tip-fill column.** - `'conversation.composer.dock'` entries reach the page as the `ComposerBarOwnerProps.footer` slot, rendered under the card inside the bar's `.root`, so the stats line and the card share one width constraint. `StatsLine` derives everything client-side from the snapshot: turns/steps, LLM wall time from assistant `timing` (`completedTime - stepStartTime`), tool wall time from tool-result `time - callTime` pairs, prompt/output token split with cache-read folded into input, and cache-hit percentage. Groups render pipe-separated and drop out whole when empty; `formatTokens` (517 / 12.2K / 1.2M) and `formatDuration` (45.2s / 2m42s) are exported for tests. Durations cover only in-window nodes — the README owns that limitation. -- `.composerStack` carries `gap: 8px` and entries carry no outer margins (QueueDock's margin removed), so a dock entry that renders null costs nothing. GoalBar is the one deliberate exception: `margin: 0 auto -10px` cancels the gap and tucks its square bottom edge 2px under the card. +- `.composerStack` carries the Figma composition matrix's 6px gap. Goal and Todo remain standalone cards; the terminal Queue entry subtracts that gap plus the named 5px layout overlap so the later composer card paints over only the queue edge. The [composer context stack decision](../bug-fix/2026-07-30-composer-context-stack-order.md) owns the order and overlap contract. - The sticky seat's background is a `linear-gradient` from `color-mix(bg-base 0%, transparent)` at 0px to solid `bg-base` at 36px — pixel stops, not the figma export's percentage, so a growing draft widens only the solid region; `color-mix` keeps both themes fading from their own base. - A `useCallback` ref on the seat attaches a ResizeObserver that publishes `--dsh-composer-height` on the scroll body; ChatView's back-to-bottom slot computes `bottom` from it (152px first-paint fallback) instead of the prior hardcoded 168px. -- The textarea's 52px two-line floor applies to the hero variant only; the docked composer collapses to content height. Goal and todo strips both use the 44px-gutter / 752px-cap column with the todo `tip` fill and l1 border; the todo header is compacted (13/20 type, 8+8 padding) so its collapsed height equals the goal strip's 38px. +- The textarea's 52px two-line floor applies to the hero variant only; the docked composer collapses to content height. Goal, Todo, and Queue panels use the 44px-gutter / 752px-cap column with `tip` fill and l1 border; the standalone Goal and collapsed Todo cards are 36px and 44px tall. ## Alternatives considered **Percentage gradient stops (the figma export's 24%).** Rejected: the stop scales with seat height, so a tall draft stretches the fade band over most of the transcript; the fixed 36px band equals the design's 24% at the resting ~150px composer and stays constant as the composer grows. -**A skeleton-owned dock column with a generic "bottommost entry tucks" contract.** Built and backed out in review: a `.inputDock` wrapper owning width/rhythm plus `--dsh-dock-tuck-*` vars on `:last-child` would retarget the tuck automatically on reorder, but it rewrote every entry and the GoalBar DOM ahead of a pending merge. Per-entry CSS with GoalBar owning its own tuck was chosen; the generic column remains available if dock entries multiply. +**A generic "bottommost entry tucks" contract.** Rejected because Goal and Todo are independent cards even when either is the last visible dock entry. Queue owns the one intentional composer overlap, while the stack owns its shared gap and overlap values. **Backend-supplied duration fields for the stats line.** Unnecessary: assistant `timing` and tool call/result pairs already reach the snapshot, so wall times fold client-side with no new session event or host projection. @@ -30,4 +30,4 @@ The web composer footer showed a single joined stats string (cache/tokens/turns/ ## Consequences -The stats row now reads turns/steps, LLM and tool durations, cache hit, and input/output tokens at a glance, at the cost that durations cover only the loaded event window (README Known Limitation). The one-gap stack rhythm makes dock spacing composition-independent, but GoalBar's tuck is positional: it must stay the bottommost dock entry (`order: 1`) or its negative margin tucks it under the wrong neighbor. The fade band is a constant 36px, so any future design retune is one stop value. `chat-stats-bash-sample.spec.tsx` pins the derivation (timing/tool folds, token split), both formatters, the grouped render, and the zero-renders-during-streaming acceptance. +The stats row reads turns/steps, LLM and tool durations, cache hit, and input/output tokens at a glance, at the cost that durations cover only the loaded event window (README Known Limitation). The stack's fixed order keeps standalone context cards independent and makes Queue the only panel joined to the composer; a future dock entry must choose its order relative to those roles. The fade band is a constant 36px, so any future design retune is one stop value. `chat-stats-bash-sample.spec.tsx` pins the derivation (timing/tool folds, token split), both formatters, the grouped render, and the zero-renders-during-streaming acceptance. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md index db47250852..eeba56d9f3 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md @@ -10,19 +10,19 @@ Web 编辑器页脚原本以独立 stack 行显示一条拼接的统计字符串 ## Decision -**统计行经由新的 `footer` owner prop 渲染进 InputBar 的宽度列内,并扩展为设计稿的分组细节行;composer stack 拥有唯一的 8px 节奏;座位以固定 36px 的 token 绑定渐变淡出消息流;「回到底部」控件跟随实时的 `--dsh-composer-height`;goal 与 todo 共用一条 752px 的 tip 填充列。** +**统计行经由新的 `footer` owner prop 渲染进 InputBar 的宽度列内,并扩展为设计稿的分组细节行;composer stack 拥有唯一的 6px 节奏;座位以固定 36px 的 token 绑定渐变淡出消息流;「回到底部」控件跟随实时的 `--dsh-composer-height`;goal、todo 与 queue 共用一条 752px 的 tip 填充列。** - `'conversation.composer.dock'` 条目以 `ComposerBarOwnerProps.footer` 席位到达页面,渲染在卡片下方、bar 的 `.root` 之内,统计行与卡片因此共享同一宽度约束。`StatsLine` 全部在客户端从快照推导:turns/steps、由 assistant `timing`(`completedTime - stepStartTime`)折算的 LLM 墙钟时间、由 tool-result 的 `time - callTime` 配对折算的工具墙钟时间、把 cache-read 并入输入侧的提示/输出 token 拆分,以及缓存命中率。各组以竖线分隔、无数据时整组消失;`formatTokens`(517 / 12.2K / 1.2M)与 `formatDuration`(45.2s / 2m42s)导出供测试。耗时只覆盖窗口内节点——该限制由 README 记录。 -- `.composerStack` 携带 `gap: 8px`,条目不带外边距(QueueDock 的 margin 已删除),渲染为 null 的 dock 条目零成本。GoalBar 是唯一的刻意例外:`margin: 0 auto -10px` 抵消 gap,把方形下缘塞进卡片下方 2px。 +- `.composerStack` 采用 Figma 组合矩阵中的 6px 间距。Goal 与 Todo 保持为独立卡片;末端的 Queue 条目减去这段间距及具名的 5px 布局重叠量,使后渲染的 composer 卡片只覆盖 Queue 边缘。[composer 上下文堆栈决策](../bug-fix/2026-07-30-composer-context-stack-order.md) 规定顺序与重叠契约。 - sticky 座位的背景是从 0px 处的 `color-mix(bg-base 0%, transparent)` 到 36px 处纯色 `bg-base` 的 `linear-gradient`——像素节点而非 figma 导出的百分比,草稿长高只扩大纯色区域;`color-mix` 让两个主题都从各自的底色淡出。 - 座位上的 `useCallback` ref 挂 ResizeObserver,把 `--dsh-composer-height` 发布到滚动体上;ChatView 的回到底部席位据此计算 `bottom`(首帧回退 152px),替换先前硬编码的 168px。 -- textarea 的 52px 两行下限只保留在 hero 变体;停靠态编辑器折叠到内容高度。goal 与 todo 条统一使用 44px 边距/752px 上限的列、todo 的 `tip` 填充与 l1 边框;todo 表头紧凑化(13/20 字号、8+8 内边距),折叠高度与 goal 条的 38px 对齐。 +- textarea 的 52px 两行下限只保留在 hero 变体;停靠态编辑器折叠到内容高度。Goal、Todo 与 Queue 面板统一使用 44px 边距/752px 上限的列,并采用 `tip` 填充和 l1 边框;独立的 Goal 卡片与折叠后的 Todo 卡片高度分别为 36px 和 44px。 ## Alternatives considered **百分比渐变节点(figma 导出的 24%)。** 否决:节点随座位高度缩放,长草稿会把过渡带拉伸到消息流的大半;固定 36px 过渡带等于设计稿在静息 ~150px 编辑器下的 24%,且随编辑器长高保持恒定。 -**骨架拥有的 dock 列加通用「最底条目贴卡」契约。** 实现后在评审中撤回:由 `.inputDock` 包装层拥有宽度/节奏、在 `:last-child` 上发布 `--dsh-dock-tuck-*` 变量,重排时贴卡会自动换人,但它在一次待合并前重写了每个条目和 GoalBar 的 DOM。最终选择逐条目 CSS、GoalBar 自持贴卡;dock 条目增多时通用列方案仍然可用。 +**通用「最底条目贴卡」契约。** 不予采纳,因为 Goal 和 Todo 即使成为最后一个可见 dock 条目,也仍是独立卡片。Queue 拥有唯一一处有意的 composer 重叠,而 stack 拥有共享的间距和重叠量。 **由后端为统计行提供耗时字段。** 不必要:assistant `timing` 与工具 call/result 配对已经到达快照,墙钟时间可在客户端折算,无需新的会话事件或 host 投影。 @@ -30,4 +30,4 @@ Web 编辑器页脚原本以独立 stack 行显示一条拼接的统计字符串 ## Consequences -统计行现在一眼可读 turns/steps、LLM 与工具耗时、缓存命中和输入/输出 token,代价是耗时只覆盖已加载事件窗口(README 已知限制)。单 gap 的 stack 节奏使 dock 间距与组合无关,但 GoalBar 的贴卡是位置性的:它必须保持为最底的 dock 条目(`order: 1`),否则其负边距会塞到错误的邻居下面。过渡带恒为 36px,未来设计调整只改一个节点值。`chat-stats-bash-sample.spec.tsx` 钉住推导(timing/工具折算、token 拆分)、两个格式化器、分组渲染,以及流式期间零重渲染的验收。 +统计行现在一眼可读 turns/steps、LLM 与工具耗时、缓存命中和输入/输出 token,代价是耗时只覆盖已加载事件窗口(README 已知限制)。stack 的固定顺序使独立的上下文卡片彼此分离,并让 Queue 成为唯一与 composer 相接的面板;未来新增 dock 条目时,必须选择自己相对于这些角色的顺序。过渡带恒为 36px,未来设计调整只改一个节点值。`chat-stats-bash-sample.spec.tsx` 钉住推导(timing/工具折算、token 拆分)、两个格式化器、分组渲染,以及流式期间零重渲染的验收。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 654722b589..7086e96ef1 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 3973c14f2b8fe746549bb74af85a7a60a7d66aea -README.zh.md: a6bb15c4cdd53d05bf28147b97d9d64d1c59da2b +README.md: b45139ae4f9c5f89927c1ba61c140bc7e0c12e78 +README.zh.md: ef989e716d1d3c860f101e27757b30a7b3d2d44c diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 3973c14f2b..b45139ae4f 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -16,7 +16,7 @@ A tool call declaring the `terminal` render intent renders its command output in Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). -The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. +The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 10` — between Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index a6bb15c4cd..ef989e716d 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -16,7 +16,7 @@ 审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `command` 回调提交 `/permission ` 命令行。 -todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 +todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 10` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 和 Queue 之间),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index 4c05c2cbca..51d0737ee7 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -5,9 +5,11 @@ flex: none; width: 100%; max-width: 776px; - /* Eat InputBar's 6px top padding and tuck the panel 2px under the card; - the later composer sibling paints its surface and shadow over this edge. */ - margin: 0 auto -10px; + /* Flex gap still applies after this item; subtract it together with the + design's overlap so the later composer paints over the queue edge. */ + margin: 0 auto calc( + 0px - var(--dsh-composer-stack-gap) - var(--dsh-queue-composer-overlap) + ); padding: 2px 12px; } diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 99b91f301d..88300f6086 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -162,14 +162,14 @@ export const queueDockEntry = { name: 'conversation-queue-dock', inject: ['slots', 'conversation', 'sessions'], /** - * Register the queue strip into the input dock (list entry, order 0). + * Register the queue strip as the terminal input-dock entry (order 20). * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). */ apply(ctx: Context): void { ctx.slots.register({ name: 'conversation.input.dock', id: 'queue', - order: 0, + order: 20, inject: (sessionId: SessionId): QueueDockInjected => { const actx = ctx.sessions.scope(sessionId) if (actx === undefined) throw new Error(`queue dock: session "${sessionId}" resolved no scope`) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index e240fea889..e48d25ab45 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -127,14 +127,15 @@ min-height: 0; } -/* Composer stack: dock strips above the input card (design §6 MIX order). - The stack owns the vertical rhythm: one gap here, entries carry no outer - margins — an entry that renders null costs nothing, so spacing stays - correct for any dock combination. */ +/* Composer context stack (Figma 9:937): standalone dock cards share one + rhythm; the terminal queue strip additionally tucks under the input card. */ .composerStack { + --dsh-composer-stack-gap: 6px; + --dsh-queue-composer-overlap: 5px; + display: flex; flex-direction: column; - gap: 8px; + gap: var(--dsh-composer-stack-gap); } /* Common seat for the composer chain (fallback + elected overlay siblings). */ diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css index 716f3d9419..5d26aa4a5e 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -1,9 +1,8 @@ -/* Todo strip above the composer (figma 772:51905 / 772:52972 / 772:53419): - tip surface, 14px radius, status icons + secondary item labels. Column is - calc(100% - 88px) / max 752 (GoalBar's column), centered; the composer - stack owns the gap. */ +/* Todo strip in the composer context stack (Figma 9:959): tip surface, + 14px radius, status icons + secondary item labels. */ .root { + box-sizing: border-box; flex: none; overflow: hidden; margin: 0 auto; @@ -21,13 +20,11 @@ --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } -/* Compact scale (GoalBar reference): collapsed header totals the goal - strip's 38px (8+8 pad + 20 line + 2 border). */ .body { display: flex; flex-direction: column; gap: 8px; - padding: 8px 14px; + padding: 9px 15px; } .header { @@ -44,8 +41,8 @@ .title { flex: none; - font-size: 13px; - line-height: 20px; + font-size: 14px; + line-height: 24px; font-weight: 500; color: var(--dsw-alias-label-primary); } diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx index 22f5786ab1..1dd12a6564 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx @@ -135,10 +135,10 @@ export const todoDockEntry = { name: 'conversation-todo-dock', inject: ['slots', 'conversation'], /** - * Register the plan strip into the input dock (list entry, above the queue rows). + * Register the plan strip between the goal and queue entries (order 10). * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). */ apply(ctx: Context): void { - ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: -1 }, TodoDock) + ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 10 }, TodoDock) }, } diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index 8d0614d2e9..6175cd352e 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -197,9 +197,14 @@ describe('QueueDock', () => { expect(container.innerHTML).toBe('') }) - it('ships the session-scoped registrant plugin shape', () => { + it('registers as the terminal composer-context entry', () => { expect(queueDockEntry.name).toBe('conversation-queue-dock') expect(queueDockEntry.inject).toEqual(['slots', 'conversation', 'sessions']) - expect(typeof queueDockEntry.apply).toBe('function') + const register = vi.fn() + queueDockEntry.apply({ slots: { register } } as never) + expect(register).toHaveBeenCalledWith( + expect.objectContaining({ name: 'conversation.input.dock', id: 'queue', order: 20 }), + QueueDock, + ) }) }) diff --git a/packages/client/ui-conversation/tests/todo-panel.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.spec.tsx index 65146160da..e2303039af 100644 --- a/packages/client/ui-conversation/tests/todo-panel.spec.tsx +++ b/packages/client/ui-conversation/tests/todo-panel.spec.tsx @@ -92,12 +92,12 @@ describe('TodoDock', () => { expect(screen.queryByTestId('todo-panel')).toBeNull() }) - it('ships the registrant plugin shape (list entry above the queue rows)', () => { + it('registers between the goal and queue entries', () => { expect(todoDockEntry.name).toBe('conversation-todo-dock') expect(todoDockEntry.inject).toEqual(['slots', 'conversation']) const register = vi.fn() todoDockEntry.apply({ slots: { register } } as never) - expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: -1 }, TodoDock) + expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 10 }, TodoDock) }) }) diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index 666a6e472e..191dade4fa 100644 --- a/packages/client/ui-goal/README.i18n.yaml +++ b/packages/client/ui-goal/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-goal/README.md -README.md: fed4870f73277b22760417297d668853b8afb2db -README.zh.md: cc607edc856e04c6ee42cc8f596aa658679a02ca +README.md: 2c109ab1fbe0b566b8749a6af44ec5e0055fe3b2 +README.zh.md: b81113c67566fd834b3ddb10931d4ecc630aa2f9 diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index fed4870f73..2c109ab1fb 100644 --- a/packages/client/ui-goal/README.md +++ b/packages/client/ui-goal/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Goal surface plugin, browser half: the `GoalBar` strip in the `conversation.input.dock` list (order 1, tucked against the composer). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no store, no refresh chain, and no event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline (the RPC's compare-and-set is the staleness guard — there is no client fence). Goal creation stays on the `/goal` host command; loading, absent, and completed goals render nothing. +Goal surface plugin, browser half: the `GoalBar` strip is the first standalone card in the `conversation.input.dock` composer-context stack (order 0, before Todo and Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no store, no refresh chain, and no event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline (the RPC's compare-and-set is the staleness guard — there is no client fence). Goal creation stays on the `/goal` host command; loading, absent, and completed goals render nothing. The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types. diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md index cc607edc85..b81113c675 100644 --- a/packages/client/ui-goal/README.zh.md +++ b/packages/client/ui-goal/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Goal 表面插件(浏览器半件):`conversation.input.dock` 列表中的 `GoalBar` 条带(order 1,紧贴 composer)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,走 `goal.*` 协议域——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现(RPC 的 compare-and-set 即陈旧性防护——客户端没有任何栅栏)。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成三种状态一律不渲染。 +Goal 表面插件(浏览器半件):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第一张独立卡片(order 0,位于 Todo 和 Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,走 `goal.*` 协议域——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现(RPC 的 compare-and-set 即陈旧性防护——客户端没有任何栅栏)。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成三种状态一律不渲染。 `/client` 出口面为插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。 diff --git a/packages/client/ui-goal/src/client/GoalBar.module.css b/packages/client/ui-goal/src/client/GoalBar.module.css index aaf832464e..261c4bfa0a 100644 --- a/packages/client/ui-goal/src/client/GoalBar.module.css +++ b/packages/client/ui-goal/src/client/GoalBar.module.css @@ -1,29 +1,24 @@ -/* GoalBar: the goal strip docked above the composer card. The dock's 44px - side padding and the bar's 752px cap match the todo strip's column - (TodoPanel.module.css), 24px inside the composer card's edges. The - negative bottom margin cancels the composer stack's 8px gap and tucks the - bar's square bottom edge 2px under the composer card's top edge (the - card, later in DOM order, paints over it). Surface matches the todo - strip: tip fill, l1 border — no bottom edge where it disappears under the - card. All states share one fixed 38px height so switching between them - never resizes the strip. */ +/* GoalBar: the first standalone card in the composer context stack (Figma + 9:939). Its 752px column matches Todo and the Queue panel. */ .dock { + box-sizing: border-box; + width: 100%; padding: 0 44px; } .bar { + box-sizing: border-box; display: flex; align-items: center; - gap: 6px; - box-sizing: border-box; + gap: 10px; + width: 100%; max-width: 752px; - height: 38px; - margin: 0 auto -10px; - padding: 0 14px; + height: 36px; + margin: 0 auto; + padding: 4px 5px 4px 12px; border: 1px solid var(--dsw-alias-border-l1); - border-bottom: none; - border-radius: 14px 14px 0 0; + border-radius: 14px; background: var(--dsw-specific-tip); } @@ -37,8 +32,8 @@ flex: none; font-size: 13px; line-height: 20px; - font-weight: 600; - color: var(--dsw-alias-label-primary); + font-weight: 500; + color: var(--dsw-alias-label-primary-dimmed); } .objective { @@ -47,7 +42,7 @@ overflow: hidden; font-size: 13px; line-height: 20px; - color: var(--dsw-alias-label-secondary); + color: var(--dsw-alias-label-primary-dimmed); text-overflow: ellipsis; white-space: nowrap; } @@ -92,7 +87,7 @@ .actions { display: flex; align-items: center; - gap: 8px; + gap: 10px; flex: none; } @@ -100,11 +95,11 @@ display: inline-flex; align-items: center; justify-content: center; - width: 24px; - height: 24px; + width: 28px; + height: 28px; padding: 0; border: none; - border-radius: 6px; + border-radius: 999px; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index fc1cf1e035..9ea8e7134a 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -59,7 +59,7 @@ export function apply(ctx: ClientContext): void { scope.effect(() => scope.slots.register({ name: 'conversation.input.dock', id: 'goal', - order: 1, + order: 0, inject: (sessionId): GoalBarActions => ({ onEdit: async (objective) => { const ref = refOf(sessionId) diff --git a/packages/client/ui-goal/tests/browser-plugin.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.spec.tsx index 348d06d1a6..c560ba4b8c 100644 --- a/packages/client/ui-goal/tests/browser-plugin.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.spec.tsx @@ -92,7 +92,7 @@ describe('ui-goal browser plugin', () => { it('registers the GoalBar dock entry with the documented id and order', async () => { const b = bench() await b.fiber.await() - expect(b.entry()).toMatchObject({ id: 'goal', order: 1 }) + expect(b.entry()).toMatchObject({ id: 'goal', order: 0 }) expect(b.entry()?.inject).toBeTypeOf('function') }) From 101c3908f37c6a190329293c8b536b5f12d405e2 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 11:50:16 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(directory-picker-browse):=20quiet=20na?= =?UTF-8?q?vigation=20=E2=80=94=20one-frame=20landings=20and=20a=20slow-sc?= =?UTF-8?q?an=20loading=20pill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Navigations keep the previous view rendering while scanning: target and parent legs land as one two-pane frame when the parent leg settles within a 200ms wait bound (past it the target lands alone and the late leg upgrades in place; Escape inside the landing window withdraws the navigation). The loading indicator floats over the content on the card background and appears only once a scan outlives a 300ms silence window, so navigation never shifts the columns or flashes an intermediate frame. The truncated note now describes the on-screen panes instead of hiding during scans. --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- .../directory-picker-browse/README.i18n.yaml | 4 +- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- .../src/client/DirectoryBrowser.module.css | 15 ++ .../src/client/DirectoryBrowser.tsx | 116 ++++++++++++---- .../tests/directory-browser.spec.tsx | 128 +++++++++++++++++- 9 files changed, 235 insertions(+), 40 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index 900e1d01b6..edc0afb4c5 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: ad2aa904beddb2fe941883c3c1827702dbec9964 -2026-07-28-directory-picker-capability-seam.zh.md: 30e719ad9b4e8374496106b447e961a042c7d8b6 +2026-07-28-directory-picker-capability-seam.md: cfe0de43294439fadca2d7bc40a8c175d2cda372 +2026-07-28-directory-picker-capability-seam.zh.md: 7e2e16aa24bb4430667bdc1a5c12dfd1bd3a2e4f diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index ad2aa904be..cfe0de4329 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -20,7 +20,7 @@ Placement and policy rulings folded into this decision: - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. - **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. -- **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. +- **Navigation lands selection-anchored, quiet, and bounded.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the landing is two-pane: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. Target and parent legs land as **one frame** when the parent leg settles within the 200ms wait bound — the stale view keeps rendering until then, so navigation swaps the panes without an intermediate single-pane flash — and past the bound the target commits alone at once (an Enter-submitted navigation is never held hostage by a stalled parent) with the late parent leg upgrading the landing in place. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent (Escape inside the landing window therefore withdraws the whole navigation); a failed parent leg, or a truncated parent window lacking the target, leaves the single-pane landing — the upgrade must never orphan the selection it exists to anchor. The loading indicator follows the same quiet rule: it floats over the content (never a layout-shifting row) and only once a scan outlives a 300ms silence window, so a local listing swaps with nothing shown at all. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 30e719ad9b..7e2e16aa24 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -20,7 +20,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 - **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。 -- **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。 +- **导航以选中项为锚、安静且有界地落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),落地即双栏:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在 200ms 等待上限内落定时,目标与父层级两程以**同一帧**落地——在此之前陈旧视图持续渲染,导航换栏时因此没有中间的单栏闪现——超出该上限则目标即刻单独提交(Enter 提交的导航绝不会被滞塞的父层级扣作人质),迟到的父层级这一程再就地升级这次落地。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止(因此在落地窗口内按 Escape 即撤回整次导航);父层级这一程失败,或被截断的父窗口缺少目标时,都保留单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。加载指示器遵循同一安静规则:它浮于内容之上(绝不是会挪动布局的一行),且仅在扫描超出 300ms 静默窗口后才出现,因此本地列举切换时什么也不显示。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index d807bd737a..673d053e3a 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md -README.md: 23153881b84dcb71dfb05d4f297a5818c410ca77 -README.zh.md: d7010e2941a801ba6358082824330eaae46e42b7 +README.md: 52b5fe7e89f915be3b50324628e9d5c48f1ef94c +README.zh.md: 742da39470083887a71ddba4a7c8012f0ce0ea1f diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 23153881b8..52b5fe7e89 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). -**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored: a crumb jump or a submitted path commits the target immediately, then re-selects its actual entry in its parent level once that level arrives — two panes, so stepping back never collapses (a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft); a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from both filters; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). +**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft); a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from both filters; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index d7010e2941..742da39470 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -6,7 +6,7 @@ 行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 -**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚落地:crumb 跳转或提交的路径会立即提交目标,待父层级到达后再在其中重新选中目标的实际条目——双栏,因此后退绝不塌缩(父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;Enter 仍按确切文本导航)、按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿);基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受这两种过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 +**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染("Loading…" 胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;Enter 仍按确切文本导航)、按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿);基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受这两种过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index 85349962e5..bfa50aa987 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -146,11 +146,26 @@ flex-direction: column; flex: 1 1 0; min-height: 0; + /* Anchors the floating loading pill (.loadingFloat). */ + position: relative; /* Right inset is slimmer than the left: the trailing column's own 8px * scrollbar clearance makes up the optical difference. */ padding: 16px 16px 16px 24px; } +/* The slow-scan indicator floats over the content's bottom-left on the card + * background instead of occupying a row: a scan must never shift the + * columns' height, and the stale view keeps rendering beneath it (it only + * appears at all once a scan outlives SLOW_SCAN_DELAY_MS). */ +.loadingFloat { + position: absolute; + left: 24px; + bottom: 8px; + padding: 2px 8px; + border-radius: 6px; + background: var(--dsw-alias-bg-layer-2); +} + /* Columns split the row evenly around the divider (a solo column takes the * whole row); 256px is the floor below which the row scrolls (scrollbar * hidden, the effect pins the child pane into view) instead of squeezing diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 859667d115..0610dbb8a6 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -5,10 +5,12 @@ * breadcrumb, and a click-to-edit path zone; below it a Miller view — one * full-width level until a row is selected, then two columns splitting the * row evenly (256px floor; level | selected folder's children) around a - * hairline divider. Navigations land selection-anchored: a crumb jump or a - * submitted path commits the target immediately, then re-selects it in its - * parent level once that level arrives, so stepping back keeps two panes - * away from the display root. Selecting in the + * hairline divider. Navigations land selection-anchored and quiet: the + * previous view keeps rendering while a crumb jump or a submitted path is + * scanned, then target and parent legs land as one two-pane frame (a slow + * parent leg falls back to landing the target alone and upgrading in + * place), so stepping back keeps two panes away from the display root and + * navigation never flashes an intermediate frame. Selecting in the * right column shifts the view one level deeper. "New folder" opens a nested * create dialog targeting the selected folder (or the level itself) and * selects the created folder. Open adopts the selected folder, falling back @@ -55,6 +57,24 @@ function failureText(error: unknown): string { return error instanceof Error ? error.message : String(error) } +/** + * How long a scan may stay visually silent before the floating "Loading…" + * pill appears. The stale view keeps rendering while a scan is in flight, so + * a listing that settles inside this window swaps the panes with no + * intermediate frame at all; only a genuinely slow host (a network mount, a + * cold disk) surfaces the indicator. + */ +const SLOW_SCAN_DELAY_MS = 300 + +/** + * How long a navigation landing waits for its parent leg before committing + * the target alone. Inside the window both legs land as ONE two-pane frame — + * no single-pane flash between them; past it the target commits single-pane + * at once (an Enter-submitted navigation is never held hostage by a stalled + * parent) and the late parent leg upgrades the landing in place. + */ +const PARENT_LEG_WAIT_MS = 200 + /** * Breadcrumb rows for display: inside the home subtree the chain starts at a * localized Home crumb; outside it the full ancestry shows, the root labeled @@ -166,6 +186,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const [selected, setSelected] = useState(null) const [child, setChild] = useState(null) const [loading, setLoading] = useState(false) + // Derived from `loading` by the slow-scan effect below: true only once a + // scan has been in flight for SLOW_SCAN_DELAY_MS, so fast listings never + // render the indicator at all. + const [slowScan, setSlowScan] = useState(false) const [error, setError] = useState(null) // Path-edit state: null = breadcrumb mode; a string = the draft being typed. const [pathDraft, setPathDraft] = useState(null) @@ -228,17 +252,20 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }, [listDirectory]) /** - * Replace the whole view with a freshly navigated level. The target level - * commits the moment it arrives (single wide level: the editor closes and - * loading ends on this first settlement, so an Enter-submitted navigation - * is never withdrawn waiting on anything further). Away from the display - * root — the same collapse the crumb header renders, so crumbs and pane - * shape never disagree — a parent leg then upgrades the landing in place: - * the target's ACTUAL parent-level entry re-selected (left pane = parent, - * right pane = the target), so a crumb jump reads as stepping back one - * pane. A failed parent leg, or a truncated parent window that lacks the - * target, leaves the committed single-pane landing — the upgrade must - * never orphan the selection it exists to anchor. + * Replace the whole view with a freshly navigated level. Away from the + * display root — the same collapse the crumb header renders, so crumbs and + * pane shape never disagree — the landing is two-pane: the target's ACTUAL + * parent-level entry re-selected (left pane = parent, right pane = the + * target), so a crumb jump reads as stepping back one pane. Both legs land + * as one frame when the parent leg settles within + * {@link PARENT_LEG_WAIT_MS}; past that bound (or at the display root) the + * target commits alone — single wide level, the editor closes, loading + * ends — and a late parent leg still upgrades the landing in place. A + * failed parent leg, or a truncated parent window that lacks the target, + * leaves the single-pane landing — the upgrade must never orphan the + * selection it exists to anchor. Until whichever commit comes first, the + * previous view keeps rendering: navigation swaps the panes, it never + * blanks them. */ const navigate = useCallback((path?: string) => { const { seq, scan } = launchListing(path) @@ -246,16 +273,23 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setError(null) scan.then((target) => { if (seq !== requestSeq.current) return - setParent(target) - setSelected(null) - setChild(null) - setLoading(false) - setPathDraft(null) + // The single-pane landing; `landed` makes it first-commit-only, while + // the two-pane commit below may still upgrade an already-landed view. + let landed = false + const landSingle = (): void => { + if (landed || seq !== requestSeq.current) return + landed = true + setParent(target) + setSelected(null) + setChild(null) + setLoading(false) + setPathDraft(null) + } // Arity is label-independent: only the collapsed chain's depth decides. - if (displayCrumbs(target, '').length < 2) return + if (displayCrumbs(target, '').length < 2) { landSingle(); return } const parentCrumb = target.crumbs.at(-2) /* v8 ignore next -- narrowing: a two-deep display chain implies a parent crumb (root-to-target inclusive). */ - if (parentCrumb === undefined) return + if (parentCrumb === undefined) { landSingle(); return } continueScan(parentCrumb.path).then((parentLevel) => { if (seq !== requestSeq.current) return // Windows resolves a typed path preserving its case; anchor on the @@ -263,15 +297,23 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const sep = separatorOf(parentLevel) const fold = (value: string): string => (sep === '\\' ? value.toLowerCase() : value) const match = parentLevel.entries.find(entry => fold(entry.path) === fold(target.path)) - if (match === undefined) return + if (match === undefined) { landSingle(); return } + landed = true setParent(parentLevel) setSelected(match) setChild(target) + // Idempotent on a late upgrade of a timed-out landing: reopening the + // editor or starting a newer scan supersedes this seq, so reaching + // here means the draft is closed and the loading flag is this + // navigation's own. + setLoading(false) + setPathDraft(null) }, () => { - // Swallows the parent-leg failure (its abort included): the - // committed single-pane landing stands, and nobody asked to see - // the parent level. + // The parent-leg failure (its abort included) never surfaces: the + // target listed fine, and nobody asked to see the parent level. + landSingle() }) + window.setTimeout(landSingle, PARENT_LEG_WAIT_MS) }, (reason: unknown) => { if (seq !== requestSeq.current) return setLoading(false) @@ -415,6 +457,18 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }) } + // The slow-scan gate for the loading indicator: arm a timer when a scan + // starts, retire it (and the indicator) the moment loading ends. A settle + // inside the window means the swap happened with nothing shown. + useEffect(() => { + if (!loading) { + setSlowScan(false) + return + } + const timer = window.setTimeout(() => { setSlowScan(true) }, SLOW_SCAN_DELAY_MS) + return () => { window.clearTimeout(timer) } + }, [loading]) + // After the hooks: a closed dialog renders nothing and evaluates no copy. const crumbSource = child ?? parent const crumbs = crumbSource === null ? [] : displayCrumbs(crumbSource, t('browser.home')) @@ -649,11 +703,15 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, /> )} - {loading &&
{t('browser.loading')}
} + {loading && slowScan + &&
{t('browser.loading')}
} {/* The backend bounds a level at its complete-result limit; say so * whenever a visible pane was cut instead of letting the tail of a - * huge directory go silently missing. */} - {(parent?.truncated === true || child?.truncated === true) && !loading + * huge directory go silently missing. The note describes the panes + * on screen, so an in-flight scan leaves it alone — hiding it while + * the stale view still shows the cut level would shift the columns + * on every navigation away from it. */} + {(parent?.truncated === true || child?.truncated === true) &&
{t('browser.truncated')}
} {error !== null &&
{error}
} diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 6fa9bb999f..2528af4b46 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -235,7 +235,7 @@ describe('DirectoryBrowser', () => { expect(columns()).toHaveLength(1) }) - it('commits the target immediately, aborts a superseded parent leg on the wire, and drops its late resolution', async () => { + it('lands the target single-pane at the wait bound, aborts a superseded parent leg on the wire, and drops its late resolution', async () => { const signals: (AbortSignal | undefined)[] = [] const settlers: ((value: DirectoryListing) => void)[] = [] // Only the FIRST explicit HOME request (the parent leg) hangs; the later @@ -253,8 +253,8 @@ describe('DirectoryBrowser', () => { fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) - // The target leg commits at once: editor closed, single-pane DOCS level, - // while the parent leg (upgrade) is still in flight. + // The parent leg (upgrade) hangs past the landing wait bound: the target + // commits alone — editor closed, single-pane DOCS level. await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() expect(columns()).toHaveLength(1) @@ -270,6 +270,128 @@ describe('DirectoryBrowser', () => { expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull() }) + /** + * Listing fake whose explicit-path scans stay pending until the test + * settles them by path; the absent-path form (the initial home listing) + * resolves normally so mounting is a one-flush setup. + */ + function manualLister() { + const settlers = new Map void>() + const listDirectory = vi.fn((path?: string, _signal?: AbortSignal) => { + if (path === undefined) return Promise.resolve(listingFor(path)) + return new Promise((resolve) => { settlers.set(path, resolve) }) + }) + return { settlers, listDirectory } + } + + it('lands a navigation as ONE two-pane frame: the stale view holds until both legs arrive', async () => { + vi.useFakeTimers() + try { + const { settlers, listDirectory } = manualLister() + mount({ listDirectory }) + await act(async () => {}) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + // The target settles while the parent leg is still in flight: nothing + // commits yet — the editor stays open over the stale home level, and no + // single-pane DOCS frame ever renders. + await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) }) + expect(screen.getByLabelText('browser.editPath', { selector: 'input' })).toBeTruthy() + expect(screen.queryByText('harness')).toBeNull() + // The parent leg settles inside the wait bound: one commit straight to + // the two-pane landing, editor closed. + await act(async () => { settlers.get(HOME)!(listingFor(HOME)) }) + expect(columns()).toHaveLength(2) + expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true') + expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() + expect(within(columns()[1]!).getByText('harness')).toBeTruthy() + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + // The wait-bound timer firing after the landing is a no-op. + await act(async () => { vi.advanceTimersByTime(200) }) + expect(columns()).toHaveLength(2) + } finally { + vi.useRealTimers() + } + }) + + it('a stalled parent leg lands the target alone at the wait bound, then upgrades in place', async () => { + vi.useFakeTimers() + try { + const { settlers, listDirectory } = manualLister() + mount({ listDirectory }) + await act(async () => {}) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) }) + // The parent leg outlives PARENT_LEG_WAIT_MS: the target lands alone. + await act(async () => { vi.advanceTimersByTime(200) }) + expect(columns()).toHaveLength(1) + expect(screen.getByRole('listitem').textContent).toBe('harness') + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + // The late parent leg still upgrades the landing in place, exactly as + // if it had made the bound. (Reopening the editor meanwhile would + // supersede the upgrade — the editor-open handler withdraws pending + // listings — so a late upgrade can never close a resumed draft.) + await act(async () => { settlers.get(HOME)!(listingFor(HOME)) }) + expect(columns()).toHaveLength(2) + expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true') + expect(within(columns()[1]!).getByText('harness')).toBeTruthy() + } finally { + vi.useRealTimers() + } + }) + + it('Escape inside the landing window withdraws the submitted navigation', async () => { + vi.useFakeTimers() + try { + const { settlers, listDirectory } = manualLister() + mount({ listDirectory }) + await act(async () => {}) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: DOCS } }) + fireEvent.keyDown(input, { key: 'Enter' }) + await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) }) + // Nothing has committed yet; Escape supersedes the landing entirely. + fireEvent.keyDown(input, { key: 'Escape' }) + await act(async () => { vi.advanceTimersByTime(200) }) + expect(columns()).toHaveLength(1) + expect(screen.queryByText('harness')).toBeNull() + expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('shows the loading indicator only once a scan outlives its silence window, floating over the stale view', async () => { + vi.useFakeTimers() + try { + const { settlers, listDirectory } = manualLister() + mount({ listDirectory }) + await act(async () => {}) + expect(screen.queryByText('browser.loading')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + // In flight but still inside the silence window: nothing shows. + expect(screen.queryByText('browser.loading')).toBeNull() + await act(async () => { vi.advanceTimersByTime(300) }) + // Past it: the indicator floats while the stale level keeps rendering. + expect(screen.getByRole('status').textContent).toBe('browser.loading') + expect(screen.getByText('Documents')).toBeTruthy() + // Landing (both legs) retires the indicator with the scan. + await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) }) + await act(async () => { settlers.get(HOME)!(listingFor(HOME)) }) + expect(screen.queryByText('browser.loading')).toBeNull() + expect(columns()).toHaveLength(2) + } finally { + vi.useRealTimers() + } + }) + it('keeps the single-pane landing when the truncated parent level lacks the target', async () => { const listDirectory = vi.fn(async (path?: string) => { // The parent leg names HOME explicitly; serve it a truncated window From 7895754ae93765fa41b1c3ef4a8111d64abce3dd Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 12:09:45 +0800 Subject: [PATCH 3/5] fix(directory-picker-browse): rebind the scrollbar elevation pair on the browser card The loading pill's layer-2 background made the sheet an elevated-surface painter, and the ui-theme scrollbar invariant rightly flagged what was already latent: the dialog's columns scroll on an l2 card while the thumbs rendered in the base-surface pair. Rebind the indirection on the card rule so it inherits to the scrolling columns. --- .../src/client/DirectoryBrowser.module.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index bfa50aa987..d440b94862 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -13,6 +13,12 @@ height: min(500px, calc(100dvh - 32px)); padding: 0; gap: 0; + /* The Modal card is an l2 surface and the columns below scroll on it: + * rebind the scrollbar indirection to the elevation pair here, on the + * surface, so it inherits down to whichever descendant scrolls (the + * rebinding contract in ui-theme styles/scrollbar.css). */ + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } /* Card-scope wrapper hosting the path editor's Escape and focus-leave From 3d26b8a6960abf8572faf99d17521ac82777fd4b Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 12:53:07 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix(directory-picker-browse):=20bot=20round?= =?UTF-8?q?=201=20=E2=80=94=20pill=20cascade+corner,=20slow-scan=20close?= =?UTF-8?q?=20reset,=20asymmetry+calibration=20recorded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .loadingFloat moved after the .status/.error block (its padding was losing the same-specificity race) and re-anchored bottom-right: the truncated/error rows own the bottom left and keep rendering through a scan, so the pill can never cover them; confirmCreate's relist now clears the stale failure text like every other scan launch. - The close edge resets loading, so the slow-scan effect disarms while hidden and a reopened dialog waits out a fresh silence window (regression test added). - The truncated note's survival through a scan is now asserted in the slow-scan test; the wait-bound test moved to fake timers with the 200ms bound explicit. - select()'s exemption from the one-frame rule and the constants' local calibration premise are recorded in JSDoc and the capability-seam Agent Note; the themed-scrollbars note's rebinding enumeration is replaced by a pointer to the mechanical gate (it had drifted twice). Both pairs re-recorded. --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- ...d-scrollbars-and-reserved-gutter.i18n.yaml | 4 +- ...8-themed-scrollbars-and-reserved-gutter.md | 2 +- ...hemed-scrollbars-and-reserved-gutter.zh.md | 2 +- .../src/client/DirectoryBrowser.module.css | 30 +++-- .../src/client/DirectoryBrowser.tsx | 19 ++- .../tests/directory-browser.spec.tsx | 127 +++++++++++++----- 9 files changed, 133 insertions(+), 59 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index edc0afb4c5..5b33f27a5c 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: cfe0de43294439fadca2d7bc40a8c175d2cda372 -2026-07-28-directory-picker-capability-seam.zh.md: 7e2e16aa24bb4430667bdc1a5c12dfd1bd3a2e4f +2026-07-28-directory-picker-capability-seam.md: 892bb4b2c4fe200df91866c4ec4cf8bb7c58e940 +2026-07-28-directory-picker-capability-seam.zh.md: d738773adc853dae7b3496f0dc2901eebd0f0a08 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index cfe0de4329..892bb4b2c4 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -20,7 +20,7 @@ Placement and policy rulings folded into this decision: - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. - **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. -- **Navigation lands selection-anchored, quiet, and bounded.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the landing is two-pane: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. Target and parent legs land as **one frame** when the parent leg settles within the 200ms wait bound — the stale view keeps rendering until then, so navigation swaps the panes without an intermediate single-pane flash — and past the bound the target commits alone at once (an Enter-submitted navigation is never held hostage by a stalled parent) with the late parent leg upgrading the landing in place. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent (Escape inside the landing window therefore withdraws the whole navigation); a failed parent leg, or a truncated parent window lacking the target, leaves the single-pane landing — the upgrade must never orphan the selection it exists to anchor. The loading indicator follows the same quiet rule: it floats over the content (never a layout-shifting row) and only once a scan outlives a 300ms silence window, so a local listing swaps with nothing shown at all. +- **Navigation lands selection-anchored, quiet, and bounded.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the landing is two-pane: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. Target and parent legs land as **one frame** when the parent leg settles within the 200ms wait bound — the stale view keeps rendering until then, so navigation swaps the panes without an intermediate single-pane flash — and past the bound the target commits alone at once (an Enter-submitted navigation is never held hostage by a stalled parent) with the late parent leg upgrading the landing in place. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent (Escape inside the landing window therefore withdraws the whole navigation); a failed parent leg, or a truncated parent window lacking the target, leaves the single-pane landing — the upgrade must never orphan the selection it exists to anchor. The loading indicator follows the same quiet rule: it floats over the content's bottom-right corner (never a layout-shifting row; the truncated/error rows own the bottom left and keep rendering through a scan) and only once a scan outlives a 300ms silence window, so a local listing swaps with nothing shown at all. Row picks are deliberately exempt from the one-frame rule: a pick's immediate pane split is its selected-state feedback (aria-current, crumbs following), while a navigation has nothing to acknowledge the click but the swap itself. Both timing constants are calibrated for local enumeration; a remote deployment (one RPC per level, commonly 100–400ms) would sit inside the silence window with no pressed state on the crumbs — revisit the window or add pressed feedback when a remote consumer lands. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 7e2e16aa24..d738773adc 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -20,7 +20,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 - **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。 -- **导航以选中项为锚、安静且有界地落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),落地即双栏:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在 200ms 等待上限内落定时,目标与父层级两程以**同一帧**落地——在此之前陈旧视图持续渲染,导航换栏时因此没有中间的单栏闪现——超出该上限则目标即刻单独提交(Enter 提交的导航绝不会被滞塞的父层级扣作人质),迟到的父层级这一程再就地升级这次落地。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止(因此在落地窗口内按 Escape 即撤回整次导航);父层级这一程失败,或被截断的父窗口缺少目标时,都保留单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。加载指示器遵循同一安静规则:它浮于内容之上(绝不是会挪动布局的一行),且仅在扫描超出 300ms 静默窗口后才出现,因此本地列举切换时什么也不显示。 +- **导航以选中项为锚、安静且有界地落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),落地即双栏:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在 200ms 等待上限内落定时,目标与父层级两程以**同一帧**落地——在此之前陈旧视图持续渲染,导航换栏时因此没有中间的单栏闪现——超出该上限则目标即刻单独提交(Enter 提交的导航绝不会被滞塞的父层级扣作人质),迟到的父层级这一程再就地升级这次落地。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止(因此在落地窗口内按 Escape 即撤回整次导航);父层级这一程失败,或被截断的父窗口缺少目标时,都保留单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。加载指示器遵循同一安静规则:它浮于内容右下角(绝不是会挪动布局的一行;截断/错误行占据左下角,并在扫描期间持续渲染),且仅在扫描超出 300ms 静默窗口后才出现,因此本地列举切换时什么也不显示。行选取被刻意豁免于同一帧规则:选取后立即分栏本身就是其选中态反馈(aria-current、crumb 跟随),而导航除了换栏本身没有任何东西可确认这次点击。两个时序常量都按本地列举校准;远程部署(每层级一次 RPC,通常 100–400ms)会落在静默窗口之内、crumb 上却没有按下态——待远程消费方落地时,重新审视该窗口或补上按下反馈。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml index a205f48f54..8099344ace 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md -2026-07-28-themed-scrollbars-and-reserved-gutter.md: 38228c868bb00210118e8110feb722fb81d0d56c -2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md: 9fafe1faa9303b5e2e23a1b3064904f71494026d +2026-07-28-themed-scrollbars-and-reserved-gutter.md: 76dcb6d9f3976faf3338a89f3ccab7182fe6d5ab +2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md: ff12c6884bec706dbbd9974684010cbcd9fa03bf diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md index 38228c868b..76dcb6d9f3 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md @@ -20,7 +20,7 @@ The rules sit on `body`, not `html`. `design-platform.css` declares the `--dsw-a The two renderings are mutually exclusive, and the exclusion is enforced rather than assumed. A non-`auto` `scrollbar-width` or `scrollbar-color` makes Chromium and Safari discard every `::-webkit-scrollbar*` rule for that element, `::-webkit-scrollbar-thumb:hover` included. Declaring both unconditionally therefore leaves the hover token rendering nowhere at all: the engines that implement the hover pseudo-element are exactly the ones the standard properties silence, and Firefox has no hover pseudo-element to fall back on. The standard properties consequently sit inside `@supports not selector(::-webkit-scrollbar)`, which is true only where the pseudo-element is unimplemented, so Firefox takes the standard path and WebKit-based engines take the pseudo-element path. The WebKit rules are not gated in turn: an engine without those pseudo-elements drops them as unknown selectors, so a gate would only restate what selector matching already does. An engine too old for the `selector()` function makes the condition invalid, which evaluates false and selects the pseudo-element path — the correct side for the pre-16.4 Safari that is the realistic case for that reading. -Both paths read one indirection pair, `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover`, bound on `body` to the l1 (base-surface) tokens. **This is the rebinding contract, and it is the part the CSS alone does not state**: an elevated surface sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container, and that one rebind reaches the standard properties and the WebKit pseudo-elements together. The pair is rebound as a pair; rebinding the resting thumb alone leaves the hover state on the base-surface token. Eight surfaces rebind today: the command popup, the slash menu, the model-select panel, the settings panel, the shared `ui-primitives` menu card, the composer input card, the question composer card, and the todo panel. Most declare it on the elevated card rather than on the scrolling descendant, because the elevation is a property of the surface and custom properties inherit down to whichever child actually scrolls. +Both paths read one indirection pair, `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover`, bound on `body` to the l1 (base-surface) tokens. **This is the rebinding contract, and it is the part the CSS alone does not state**: an elevated surface sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container, and that one rebind reaches the standard properties and the WebKit pseudo-elements together. The pair is rebound as a pair; rebinding the resting thumb alone leaves the hover state on the base-surface token. The set of rebinding surfaces is owned by the mechanical gate (`packages/client/ui-theme/tests/scrollbar-styles.spec.ts`): any sheet that scrolls and paints an elevated surface must rebind, so this note no longer enumerates them (an enumeration here drifted twice). Most declare it on the elevated card rather than on the scrolling descendant, because the elevation is a property of the surface and custom properties inherit down to whichever child actually scrolls. The last four were missed in the first implementation and found in review, which is why the rebinding contract is now checked mechanically rather than by inspection: a sheet that scrolls somewhere and paints an elevated surface somewhere must rebind. diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md index 9fafe1faa9..ff12c6884b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md @@ -20,7 +20,7 @@ Status: implemented 两种渲染互斥,而这种互斥是被强制的,不是假定的。`scrollbar-width` 或 `scrollbar-color` 只要取非 `auto` 值,Chromium 与 Safari 就会丢弃该元素上的全部 `::-webkit-scrollbar*` 规则,`::-webkit-scrollbar-thumb:hover` 也在其中。因此无条件地同时声明会让 hover token 在任何地方都得不到渲染:实现了 hover 伪元素的引擎,恰恰就是被标准属性静音的那些,而 Firefox 没有 hover 伪元素可作退路。于是标准属性写在 `@supports not selector(::-webkit-scrollbar)` 之内,该条件只在伪元素未被实现处为真,因此 Firefox 走标准属性路径,WebKit 系引擎走伪元素路径。WebKit 规则不再反向加门禁:不实现这些伪元素的引擎会把它们当作未知选择器丢弃,因此加门禁只是重述选择器匹配本身已经做的事。对于旧到不支持 `selector()` 函数的引擎,该条件无效,从而求值为假并选中伪元素路径——对于这条判断下现实存在的 16.4 之前的 Safari,这正是正确的一侧。 -两条路径都读取同一组间接变量 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover`,它们在 `body` 上绑定到 l1(基础表面)token。**这就是重新绑定契约,也是单看 CSS 无法得知的部分**:抬升表面在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`,这一次重新绑定同时作用于标准属性和 WebKit 伪元素。这组变量必须成对重新绑定;只改静止态滑块会让 hover 状态仍留在基础表面的 token 上。目前有八处抬升表面做了重新绑定:命令浮层、斜杠菜单、模型选择面板、设置面板、`ui-primitives` 共用菜单卡片、输入条卡片、提问组件卡片与待办面板。多数把声明写在抬升卡片上而非滚动的后代元素上,因为抬升层级是这个表面的属性,而自定义属性会继承到真正滚动的那个子元素。 +两条路径都读取同一组间接变量 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover`,它们在 `body` 上绑定到 l1(基础表面)token。**这就是重新绑定契约,也是单看 CSS 无法得知的部分**:抬升表面在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`,这一次重新绑定同时作用于标准属性和 WebKit 伪元素。这组变量必须成对重新绑定;只改静止态滑块会让 hover 状态仍留在基础表面的 token 上。重新绑定表面的集合归机械门禁(`packages/client/ui-theme/tests/scrollbar-styles.spec.ts`)所有:任何既滚动又绘制抬升表面的样式表都必须重新绑定,因此本 note 不再枚举它们(这里的枚举已经漂移过两次)。多数把声明写在抬升卡片上而非滚动的后代元素上,因为抬升层级是这个表面的属性,而自定义属性会继承到真正滚动的那个子元素。 后四处在最初的实现里被漏掉、由评审发现,因此重新绑定契约现在由机械检查把关,而不再依赖人工审阅:一张样式表只要在某处滚动、又在某处绘制抬升表面,就必须重新绑定。 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index d440b94862..594f450756 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -159,19 +159,6 @@ padding: 16px 16px 16px 24px; } -/* The slow-scan indicator floats over the content's bottom-left on the card - * background instead of occupying a row: a scan must never shift the - * columns' height, and the stale view keeps rendering beneath it (it only - * appears at all once a scan outlives SLOW_SCAN_DELAY_MS). */ -.loadingFloat { - position: absolute; - left: 24px; - bottom: 8px; - padding: 2px 8px; - border-radius: 6px; - background: var(--dsw-alias-bg-layer-2); -} - /* Columns split the row evenly around the divider (a solo column takes the * whole row); 256px is the floor below which the row scrolls (scrollbar * hidden, the effect pins the child pane into view) instead of squeezing @@ -267,6 +254,23 @@ color: var(--dsw-alias-state-error-primary); } +/* The slow-scan indicator floats over the content's bottom-RIGHT corner on + * the card background instead of occupying a row: a scan must never shift + * the columns' height, and the stale view keeps rendering beneath it (it + * only appears at all once a scan outlives SLOW_SCAN_DELAY_MS). Right, + * not left: the truncated/error status rows flow at the bottom LEFT and + * stay on screen through a scan, so the opposite corner keeps both + * legible. After .status in the cascade — the element carries both + * classes and this padding must win the same-specificity race. */ +.loadingFloat { + position: absolute; + right: 16px; + bottom: 8px; + padding: 2px 8px; + border-radius: 6px; + background: var(--dsw-alias-bg-layer-2); +} + /* Footer: l3 separator on top, symmetric padding so the row sits vertically * centered in the bar; New-folder and the show-hidden toggle pin left. */ .footerBar { diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 0610dbb8a6..023404f71d 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -331,7 +331,15 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const pathInputRef = useRef(null) const editZoneRef = useRef(null) - /** Select a row of the listed level and preview its children on the right. */ + /** + * Select a row of the listed level and preview its children on the right. + * Deliberately NOT one-frame like navigate(): a pick's first duty is the + * immediate selected state on the clicked row, and the pane split IS that + * feedback (aria-current pill, crumbs following the selection) — holding + * it back for the child listing would make clicks feel dropped. The quiet + * rule governs whole-view replacement, where nothing acknowledges the + * click but the swap itself. + */ const select = useCallback((entry: DirectoryEntry) => { const { seq, scan } = launchListing(entry.path) // A pick while the path editor is open adopts the (filtered) row and @@ -402,6 +410,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, return } supersede() + // Closing mid-scan leaves nothing to load: without this edge the + // slow-scan effect keeps arming while hidden and the reopened dialog + // would show the indicator on its first frame instead of waiting out a + // fresh silence window (reopen's navigate() produces no loading edge). + setLoading(false) setError(null) setPathDraft(null) setFolderDraft(null) @@ -438,6 +451,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // create target becomes the listed level and the new folder its selection. const { seq, scan } = launchListing(targetPath) setLoading(true) + // Symmetric with navigate/select: a launched scan clears the stale + // failure text (and keeps the floating indicator's corner the only + // occupant of the content's right edge while it shows). + setError(null) scan.then((level) => { /* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */ if (seq !== requestSeq.current) return diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 2528af4b46..0fe1f91e00 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -236,38 +236,49 @@ describe('DirectoryBrowser', () => { }) it('lands the target single-pane at the wait bound, aborts a superseded parent leg on the wire, and drops its late resolution', async () => { - const signals: (AbortSignal | undefined)[] = [] - const settlers: ((value: DirectoryListing) => void)[] = [] - // Only the FIRST explicit HOME request (the parent leg) hangs; the later - // home crumb jump lists normally. - let homeCalls = 0 - const listDirectory = vi.fn(async (path?: string, signal?: AbortSignal) => { - signals.push(signal) - if (path === HOME && ++homeCalls === 1) { - return new Promise((resolve) => { settlers.push(resolve) }) - } - return listingFor(path) - }) - mount({ listDirectory }) - await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) - fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) - fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) - fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) - // The parent leg (upgrade) hangs past the landing wait bound: the target - // commits alone — editor closed, single-pane DOCS level. - await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) - expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() - expect(columns()).toHaveLength(1) - await waitFor(() => { expect(settlers).toHaveLength(1) }) - // A newer jump aborts the pending parent leg ON THE WIRE, not merely - // dropping its settlement. - fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) - expect(signals[2]?.aborted).toBe(true) - await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) - // Its late resolution changes nothing either. - await act(async () => { settlers[0]!(listingFor(HOME)) }) - expect(columns()).toHaveLength(1) - expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull() + vi.useFakeTimers() + try { + const signals: (AbortSignal | undefined)[] = [] + const settlers: ((value: DirectoryListing) => void)[] = [] + // Only the FIRST explicit HOME request (the parent leg) hangs; the + // later home crumb jump lists normally. + let homeCalls = 0 + const listDirectory = vi.fn((path?: string, signal?: AbortSignal) => { + signals.push(signal) + if (path === HOME && ++homeCalls === 1) { + return new Promise((resolve) => { settlers.push(resolve) }) + } + return Promise.resolve(listingFor(path)) + }) + mount({ listDirectory }) + await act(async () => {}) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + // The target settled but the parent leg hangs: inside the wait bound + // nothing commits yet. + await act(async () => {}) + expect(settlers).toHaveLength(1) + expect(screen.getByLabelText('browser.editPath', { selector: 'input' })).toBeTruthy() + // The wait bound expires: the target commits alone — editor closed, + // single-pane DOCS level. + await act(async () => { vi.advanceTimersByTime(200) }) + expect(screen.getByRole('listitem').textContent).toBe('harness') + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + expect(columns()).toHaveLength(1) + // A newer jump aborts the pending parent leg ON THE WIRE, not merely + // dropping its settlement. + fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) + expect(signals[2]?.aborted).toBe(true) + await act(async () => {}) + expect(screen.getByRole('listitem').textContent).toBe('Documents') + // Its late resolution changes nothing either. + await act(async () => { settlers[0]!(listingFor(HOME)) }) + expect(columns()).toHaveLength(1) + expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull() + } finally { + vi.useRealTimers() + } }) /** @@ -369,29 +380,71 @@ describe('DirectoryBrowser', () => { it('shows the loading indicator only once a scan outlives its silence window, floating over the stale view', async () => { vi.useFakeTimers() try { - const { settlers, listDirectory } = manualLister() + // The home level is truncated so its note is on screen when the slow + // scan starts: dropping the note's old !loading guard means it must + // keep rendering through the scan, coexisting with the indicator. + const settlers = new Map void>() + const listDirectory = vi.fn((path?: string, _signal?: AbortSignal) => { + if (path === undefined) return Promise.resolve({ ...listingFor(path), truncated: true }) + return new Promise((resolve) => { settlers.set(path, resolve) }) + }) mount({ listDirectory }) await act(async () => {}) expect(screen.queryByText('browser.loading')).toBeNull() + expect(screen.getByText('browser.truncated')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) - // In flight but still inside the silence window: nothing shows. + // In flight but still inside the silence window: no indicator, and the + // stale level's truncated note stays put (no layout churn on launch). expect(screen.queryByText('browser.loading')).toBeNull() + expect(screen.getByText('browser.truncated')).toBeTruthy() await act(async () => { vi.advanceTimersByTime(300) }) - // Past it: the indicator floats while the stale level keeps rendering. - expect(screen.getByRole('status').textContent).toBe('browser.loading') + // Past it: the indicator floats while the stale level — truncated note + // included — keeps rendering beneath it. + expect(screen.getByText('browser.loading')).toBeTruthy() + expect(screen.getByText('browser.truncated')).toBeTruthy() expect(screen.getByText('Documents')).toBeTruthy() - // Landing (both legs) retires the indicator with the scan. + // Landing (both legs) retires the indicator with the scan, and the + // fresh listings' own truncated state replaces the stale note. await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) }) await act(async () => { settlers.get(HOME)!(listingFor(HOME)) }) expect(screen.queryByText('browser.loading')).toBeNull() + expect(screen.queryByText('browser.truncated')).toBeNull() expect(columns()).toHaveLength(2) } finally { vi.useRealTimers() } }) + it('a close mid-scan resets the slow-scan gate: reopening waits a fresh silence window', async () => { + vi.useFakeTimers() + try { + // Every home listing hangs: the initial open's scan is the one the + // close interrupts, and the reopen's scan proves the fresh window. + const settlers: ((value: DirectoryListing) => void)[] = [] + const listDirectory = vi.fn((_path?: string, _signal?: AbortSignal) => + new Promise((resolve) => { settlers.push(resolve) })) + const { view, props } = mount({ listDirectory }) + await act(async () => { vi.advanceTimersByTime(300) }) + expect(screen.getByText('browser.loading')).toBeTruthy() + // Close while the scan is in flight, then reopen: the first frame must + // wait out a fresh silence window, not inherit the armed indicator. + view.rerender() + view.rerender() + await act(async () => {}) + expect(screen.queryByText('browser.loading')).toBeNull() + await act(async () => { vi.advanceTimersByTime(300) }) + expect(screen.getByText('browser.loading')).toBeTruthy() + // The reopened scan settles normally. + await act(async () => { settlers.at(-1)!(listingFor(undefined)) }) + expect(screen.queryByText('browser.loading')).toBeNull() + expect(screen.getByText('Documents')).toBeTruthy() + } finally { + vi.useRealTimers() + } + }) + it('keeps the single-pane landing when the truncated parent level lacks the target', async () => { const listDirectory = vi.fn(async (path?: string) => { // The parent leg names HOME explicitly; serve it a truncated window From e080ff2c345aefd779b0a18ea4f95f9a1d37811a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 15:22:28 +0800 Subject: [PATCH 5/5] fix(directory-picker-browse): resolve quiet-navigation review --- ...d-scrollbars-and-reserved-gutter.i18n.yaml | 4 +- ...8-themed-scrollbars-and-reserved-gutter.md | 6 +-- ...hemed-scrollbars-and-reserved-gutter.zh.md | 6 +-- .../src/client/DirectoryBrowser.module.css | 12 ++++-- .../src/client/DirectoryBrowser.tsx | 31 +++++++++---- .../tests/directory-browser.spec.tsx | 43 ++++++++++++++++++- 6 files changed, 80 insertions(+), 22 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml index 8099344ace..45e957b824 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md -2026-07-28-themed-scrollbars-and-reserved-gutter.md: 76dcb6d9f3976faf3338a89f3ccab7182fe6d5ab -2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md: ff12c6884bec706dbbd9974684010cbcd9fa03bf +2026-07-28-themed-scrollbars-and-reserved-gutter.md: b45f70b126d083916c756afb88a8b646a4e9bb85 +2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md: 8afa36429ce7e6e061b014d63dcb20e5a642a84c diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md index 76dcb6d9f3..b45f70b126 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md @@ -20,13 +20,13 @@ The rules sit on `body`, not `html`. `design-platform.css` declares the `--dsw-a The two renderings are mutually exclusive, and the exclusion is enforced rather than assumed. A non-`auto` `scrollbar-width` or `scrollbar-color` makes Chromium and Safari discard every `::-webkit-scrollbar*` rule for that element, `::-webkit-scrollbar-thumb:hover` included. Declaring both unconditionally therefore leaves the hover token rendering nowhere at all: the engines that implement the hover pseudo-element are exactly the ones the standard properties silence, and Firefox has no hover pseudo-element to fall back on. The standard properties consequently sit inside `@supports not selector(::-webkit-scrollbar)`, which is true only where the pseudo-element is unimplemented, so Firefox takes the standard path and WebKit-based engines take the pseudo-element path. The WebKit rules are not gated in turn: an engine without those pseudo-elements drops them as unknown selectors, so a gate would only restate what selector matching already does. An engine too old for the `selector()` function makes the condition invalid, which evaluates false and selects the pseudo-element path — the correct side for the pre-16.4 Safari that is the realistic case for that reading. -Both paths read one indirection pair, `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover`, bound on `body` to the l1 (base-surface) tokens. **This is the rebinding contract, and it is the part the CSS alone does not state**: an elevated surface sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container, and that one rebind reaches the standard properties and the WebKit pseudo-elements together. The pair is rebound as a pair; rebinding the resting thumb alone leaves the hover state on the base-surface token. The set of rebinding surfaces is owned by the mechanical gate (`packages/client/ui-theme/tests/scrollbar-styles.spec.ts`): any sheet that scrolls and paints an elevated surface must rebind, so this note no longer enumerates them (an enumeration here drifted twice). Most declare it on the elevated card rather than on the scrolling descendant, because the elevation is a property of the surface and custom properties inherit down to whichever child actually scrolls. +Both paths read one indirection pair, `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover`, bound on `body` to the l1 (base-surface) tokens. **This is the rebinding contract, and it is the part the CSS alone does not state**: an elevated surface sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container, and that one rebind reaches the standard properties and the WebKit pseudo-elements together. The pair is rebound as a pair; rebinding the resting thumb alone leaves the hover state on the base-surface token. The mechanically discoverable subset is owned by `packages/client/ui-theme/tests/scrollbar-styles.spec.ts`: any sheet that both scrolls and paints an elevated surface must rebind, so this note no longer maintains a complete surface inventory. Most declare the pair on the elevated card rather than on the scrolling descendant, because elevation belongs to the surface and custom properties inherit to whichever child actually scrolls. -The last four were missed in the first implementation and found in review, which is why the rebinding contract is now checked mechanically rather than by inspection: a sheet that scrolls somewhere and paints an elevated surface somewhere must rebind. +Four surfaces — `Menu`, `InputBar`, `QuestionComposer`, and `TodoPanel` — were missed in the first implementation and found in review, which is why the per-sheet rebinding contract is checked mechanically rather than by inspection. The elevated set is resolved from the palette's own dark elevation ladder — the surface tokens whose dark value lands on `bg-layer-2` or `bg-layer-3`, which is the step the l1/l2 split encodes. Deriving it instead from the sheets that already rebind was the first attempt and is unsound: such a set can only confirm what someone already remembered, and a surface nobody has rebound yet — exactly the case the check exists for — defines itself as unelevated. `--dsw-specific-tip` proved it, resolving to the menu surface's rung while the todo panel scrolled on it unrebound and the derived check stayed green. -Scope is by token family, not by geometry: only `--dsw-alias-bg-*` and `--dsw-specific-*` name a surface. `--dsw-alias-button-*`, `--dsw-alias-interactive-*`, and `--dsw-alias-markdown-*` reach the same rungs while naming a control or an inline span that no scroll container renders its bar against. Shape cannot make that call, since a floating button legitimately carries a radius, a shadow, and a fixed size. The check is per sheet rather than per rule because the card and the descendant that scrolls are separate rules, and CSS text does not express which contains which. +Scope is by token family, not by geometry: only `--dsw-alias-bg-*` and `--dsw-specific-*` name a surface. `--dsw-alias-button-*`, `--dsw-alias-interactive-*`, and `--dsw-alias-markdown-*` reach the same rungs while naming a control or an inline span that no scroll container renders its bar against. Shape cannot make that call, since a floating button legitimately carries a radius, a shadow, and a fixed size. The check is per sheet rather than per rule because the card and the descendant that scrolls are separate rules. That approximation cannot detect a scrolling component embedded in an elevated card painted by another package's stylesheet, as `DirectoryBrowser` inside `Modal` demonstrated; cross-sheet composition remains a review and assembled-UI responsibility. The track and the corner stay transparent, so the thumb reads against whatever surface scrolls under it; only the thumb and its hover state carry a token color. diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md index ff12c6884b..8afa36429c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md @@ -20,13 +20,13 @@ Status: implemented 两种渲染互斥,而这种互斥是被强制的,不是假定的。`scrollbar-width` 或 `scrollbar-color` 只要取非 `auto` 值,Chromium 与 Safari 就会丢弃该元素上的全部 `::-webkit-scrollbar*` 规则,`::-webkit-scrollbar-thumb:hover` 也在其中。因此无条件地同时声明会让 hover token 在任何地方都得不到渲染:实现了 hover 伪元素的引擎,恰恰就是被标准属性静音的那些,而 Firefox 没有 hover 伪元素可作退路。于是标准属性写在 `@supports not selector(::-webkit-scrollbar)` 之内,该条件只在伪元素未被实现处为真,因此 Firefox 走标准属性路径,WebKit 系引擎走伪元素路径。WebKit 规则不再反向加门禁:不实现这些伪元素的引擎会把它们当作未知选择器丢弃,因此加门禁只是重述选择器匹配本身已经做的事。对于旧到不支持 `selector()` 函数的引擎,该条件无效,从而求值为假并选中伪元素路径——对于这条判断下现实存在的 16.4 之前的 Safari,这正是正确的一侧。 -两条路径都读取同一组间接变量 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover`,它们在 `body` 上绑定到 l1(基础表面)token。**这就是重新绑定契约,也是单看 CSS 无法得知的部分**:抬升表面在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`,这一次重新绑定同时作用于标准属性和 WebKit 伪元素。这组变量必须成对重新绑定;只改静止态滑块会让 hover 状态仍留在基础表面的 token 上。重新绑定表面的集合归机械门禁(`packages/client/ui-theme/tests/scrollbar-styles.spec.ts`)所有:任何既滚动又绘制抬升表面的样式表都必须重新绑定,因此本 note 不再枚举它们(这里的枚举已经漂移过两次)。多数把声明写在抬升卡片上而非滚动的后代元素上,因为抬升层级是这个表面的属性,而自定义属性会继承到真正滚动的那个子元素。 +两条路径都读取同一组间接变量 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover`,它们在 `body` 上绑定到 l1(基础表面)token。**这就是重新绑定契约,也是单看 CSS 无法得知的部分**:抬升表面在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`,这一次重新绑定同时作用于标准属性和 WebKit 伪元素。这组变量必须成对重新绑定;只改静止态滑块会让 hover 状态仍留在基础表面的 token 上。可由机械检查发现的子集归 `packages/client/ui-theme/tests/scrollbar-styles.spec.ts` 所有:任何既滚动又绘制抬升表面的样式表都必须重新绑定,因此本 note 不再维护完整的表面清单。多数把这组变量声明在抬升卡片上而非滚动的后代元素上,因为抬升层级属于这个表面,而自定义属性会继承到真正滚动的那个子元素。 -后四处在最初的实现里被漏掉、由评审发现,因此重新绑定契约现在由机械检查把关,而不再依赖人工审阅:一张样式表只要在某处滚动、又在某处绘制抬升表面,就必须重新绑定。 +`Menu`、`InputBar`、`QuestionComposer` 与 `TodoPanel` 这四个表面在最初的实现里被漏掉、由评审发现,因此逐样式表的重新绑定契约由机械检查而非人工审阅把关。 抬升表面集合是从调色板自身的暗色抬升阶梯解析出来的——暗色取值落在 `bg-layer-2` 或 `bg-layer-3` 上的那些表面 token,而这一档正是 l1/l2 之分所编码的层级差。最初的做法是从已经做了重新绑定的样式表反向推导,那是不成立的:这样得到的集合只能确认别人已经记得的部分,而尚无人重新绑定的表面——恰恰就是这项检查存在的理由——会把自己定义成「非抬升」。`--dsw-specific-tip` 证明了这一点:它解析到与菜单表面相同的那一档,待办面板在它上面滚动却没有重新绑定,而推导式的检查依然是绿的。 -判定范围依据 token 家族而非几何形状:只有 `--dsw-alias-bg-*` 与 `--dsw-specific-*` 表述的是表面。`--dsw-alias-button-*`、`--dsw-alias-interactive-*` 与 `--dsw-alias-markdown-*` 会落到相同档位,但它们表述的是控件或行内片段,没有任何滚动容器会把滚动条画在它们之上。形状无法做这个判断,因为悬浮按钮本来就会带圆角、阴影和固定尺寸。这项检查以样式表为粒度而非以规则为粒度,因为卡片与真正滚动的后代元素是两条不同的规则,而 CSS 文本无法表达谁包含谁。 +判定范围依据 token 家族而非几何形状:只有 `--dsw-alias-bg-*` 与 `--dsw-specific-*` 表述的是表面。`--dsw-alias-button-*`、`--dsw-alias-interactive-*` 与 `--dsw-alias-markdown-*` 会落到相同档位,但它们表述的是控件或行内片段,没有任何滚动容器会把滚动条画在它们之上。形状无法做这个判断,因为悬浮按钮本来就会带圆角、阴影和固定尺寸。这项检查以样式表为粒度而非以规则为粒度,因为卡片与真正滚动的后代元素是两条不同的规则。这种近似检查无法检测嵌在由另一个包的样式表绘制的抬升卡片中的滚动组件,`Modal` 内的 `DirectoryBrowser` 就证明了这一点;跨样式表的组合仍需在评审和组装后 UI 层面把关。 轨道与两条滚动条相交的角落保持透明,因此滑块是以其下滚动的任何表面为背景被看到;只有滑块及其 hover 状态带 token 颜色。 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index 594f450756..2f207e4195 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -242,6 +242,10 @@ .status, .error { padding: 4px; + /* The loading pill occupies the opposite corner while a stale status stays + * visible. Reserve its widest localized footprint so wrapped text cannot + * run underneath it on a narrow card. */ + padding-right: 120px; font-size: 12px; line-height: 18px; } @@ -259,15 +263,15 @@ * the columns' height, and the stale view keeps rendering beneath it (it * only appears at all once a scan outlives SLOW_SCAN_DELAY_MS). Right, * not left: the truncated/error status rows flow at the bottom LEFT and - * stay on screen through a scan, so the opposite corner keeps both - * legible. After .status in the cascade — the element carries both - * classes and this padding must win the same-specificity race. */ + * stay on screen through a scan, with their reserved right padding keeping + * both legible even on a narrow card. After .status in the cascade — the + * element carries both classes and this padding must win the + * same-specificity race. */ .loadingFloat { position: absolute; right: 16px; bottom: 8px; padding: 2px 8px; - border-radius: 6px; background: var(--dsw-alias-bg-layer-2); } diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 023404f71d..f5510fda7c 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -186,10 +186,14 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const [selected, setSelected] = useState(null) const [child, setChild] = useState(null) const [loading, setLoading] = useState(false) - // Derived from `loading` by the slow-scan effect below: true only once a - // scan has been in flight for SLOW_SCAN_DELAY_MS, so fast listings never - // render the indicator at all. + // Derived from `loading` and `scanWindow` by the slow-scan effect below: + // true only once the current listing call has been in flight for + // SLOW_SCAN_DELAY_MS, so fast listings never render the indicator at all. const [slowScan, setSlowScan] = useState(false) + // Every listing call owns a fresh silence window. `loading` may stay true + // across a superseding row pick or across a navigation's target and parent + // legs, so its boolean edge cannot identify the start of each scan. + const [scanWindow, setScanWindow] = useState(0) const [error, setError] = useState(null) // Path-edit state: null = breadcrumb mode; a string = the draft being typed. const [pathDraft, setPathDraft] = useState(null) @@ -233,13 +237,20 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, return ++requestSeq.current }, []) + /** Hide any prior indicator and start a fresh silence window for one listing call. */ + const restartSlowScanWindow = useCallback((): void => { + setSlowScan(false) + setScanWindow(value => value + 1) + }, []) + /** Launch one listing under a fresh controller so a later supersession can abort it. */ const launchListing = useCallback((path: string | undefined): { seq: number; scan: Promise } => { const seq = supersede() const controller = new AbortController() scanController.current = controller + restartSlowScanWindow() return { seq, scan: listDirectory(path, controller.signal) } - }, [supersede, listDirectory]) + }, [supersede, restartSlowScanWindow, listDirectory]) /** * Launch a follow-up listing under the CURRENT supersession seq: a newer @@ -248,8 +259,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const continueScan = useCallback((path: string): Promise => { const controller = new AbortController() scanController.current = controller + restartSlowScanWindow() return listDirectory(path, controller.signal) - }, [listDirectory]) + }, [restartSlowScanWindow, listDirectory]) /** * Replace the whole view with a freshly navigated level. Away from the @@ -474,9 +486,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }) } - // The slow-scan gate for the loading indicator: arm a timer when a scan - // starts, retire it (and the indicator) the moment loading ends. A settle - // inside the window means the swap happened with nothing shown. + // The slow-scan gate for the loading indicator: each listing call restarts + // the timer even when a superseding scan or a navigation's parent leg keeps + // `loading` continuously true. A settle inside its own window means the swap + // happened with nothing shown. useEffect(() => { if (!loading) { setSlowScan(false) @@ -484,7 +497,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, } const timer = window.setTimeout(() => { setSlowScan(true) }, SLOW_SCAN_DELAY_MS) return () => { window.clearTimeout(timer) } - }, [loading]) + }, [loading, scanWindow]) // After the hooks: a closed dialog renders nothing and evaluates no copy. const crumbSource = child ?? parent diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 0fe1f91e00..ce9f03fb0b 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -111,6 +111,12 @@ function rowButton(item: HTMLElement): HTMLButtonElement { } describe('DirectoryBrowser', () => { + it('renders nothing and launches no listing while initially closed', () => { + const b = mount({ open: false }) + expect(screen.queryByRole('dialog')).toBeNull() + expect(b.listDirectory).not.toHaveBeenCalled() + }) + it('opens at the Host home as one wide column, hides hidden entries, and roots the crumbs at Home', async () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) @@ -335,9 +341,16 @@ describe('DirectoryBrowser', () => { fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + // The target can consume most of the outer scan's silence window. + await act(async () => { vi.advanceTimersByTime(250) }) await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) }) + // Its parent leg gets a fresh silence window. Crossing the original + // scan's 300ms deadline therefore cannot flash the indicator during the + // bounded landing wait. + await act(async () => { vi.advanceTimersByTime(199) }) + expect(screen.queryByText('browser.loading')).toBeNull() // The parent leg outlives PARENT_LEG_WAIT_MS: the target lands alone. - await act(async () => { vi.advanceTimersByTime(200) }) + await act(async () => { vi.advanceTimersByTime(1) }) expect(columns()).toHaveLength(1) expect(screen.getByRole('listitem').textContent).toBe('harness') expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() @@ -417,6 +430,34 @@ describe('DirectoryBrowser', () => { } }) + it('restarts the silence window when a row pick supersedes a pending scan', async () => { + vi.useFakeTimers() + try { + const pending: ((value: DirectoryListing) => void)[] = [] + const listDirectory = vi.fn((path?: string, _signal?: AbortSignal) => { + if (path === undefined) return Promise.resolve(listingFor(path)) + return new Promise((resolve) => { pending.push(resolve) }) + }) + mount({ listDirectory }) + await act(async () => {}) + const documents = rowButton(screen.getByRole('listitem')) + fireEvent.click(documents) + await act(async () => { vi.advanceTimersByTime(300) }) + expect(screen.getByText('browser.loading')).toBeTruthy() + // The same row remains actionable while its preview is pending. A second + // pick starts a new listing without a false `loading` edge. + fireEvent.click(documents) + expect(screen.queryByText('browser.loading')).toBeNull() + await act(async () => { vi.advanceTimersByTime(299) }) + expect(screen.queryByText('browser.loading')).toBeNull() + await act(async () => { vi.advanceTimersByTime(1) }) + expect(screen.getByText('browser.loading')).toBeTruthy() + await act(async () => { pending.at(-1)!(listingFor(DOCS)) }) + } finally { + vi.useRealTimers() + } + }) + it('a close mid-scan resets the slow-scan gate: reopening waits a fresh silence window', async () => { vi.useFakeTimers() try {