Merge branch 'master' into docs/readme-human-polish-3

This commit is contained in:
j-xiang
2026-08-13 15:59:29 +08:00
committed by GitHub
42 changed files with 628 additions and 140 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-12-collapsed-sidebar-shared-entry-motion.md
2026-08-12-collapsed-sidebar-shared-entry-motion.md: c5bc18973db693cf9ba60800fbcf7720593dbad5
2026-08-12-collapsed-sidebar-shared-entry-motion.zh.md: b87ef6322d548061cc2e29c0e8f9896098c09041
@@ -0,0 +1,33 @@
# Agent Note: Collapsed sidebar upper controls share one entry motion
Status: implemented
Archived: 2026-08-12
English | [中文](2026-08-12-collapsed-sidebar-shared-entry-motion.zh.md)
## Problem
The collapsed sidebar rail renders four upper controls owned by two packages: the shell owns the toggle and New Session, while the workspace region owns add and search. Their opacity timing matched, but their geometry did not. Right-aligned controls moved with the narrowing column while left-aligned controls stayed fixed, so add appeared slower than search even under the same fade.
The bottom settings control has a different role. It is pinned to the rail foot and must not join the upper controls' horizontal entry.
## Decision
At the rail settle point, the four upper 36px controls start from one left-anchored layout and share one `150ms` animation from `translateX(49px)` to their final 10px inset. The shell applies the translation to its toggle and New Session seats and once to the workspace region, so add and search inherit the same path without nested transforms. Opacity uses the same animation timeline.
The settings seat uses a separate opacity-only keyframe with the same duration and easing. A page that starts collapsed renders the rail without an entry animation, and reduced-motion mode disables both keyframes.
## Alternatives considered
**Keep every rail control fixed at its final inset.** This removes the mismatch, but it also removes the requested horizontal entry from the four upper controls.
**Animate each workspace button independently.** This would duplicate shell timing inside `ui-workspace` and could apply both a region and child transform. Translating the registered region once keeps animation ownership in the sidebar shell.
**Translate the settings control with the upper controls.** Rejected because settings is a bottom-pinned foot action, not part of the upper control sequence.
## Consequences
- Toggle, New Session, add, and search follow the same horizontal coordinates throughout collapse.
- Settings fades at its final horizontal coordinate.
- Static collapsed renders retain their final geometry without startup motion.
- Style tests pin the shared animation assignments, translation distance, base anchors, and settings exception.
@@ -0,0 +1,33 @@
# Agent Note: 收起侧栏的上方控件共用同一进入动画
Status: implemented
Archived: 2026-08-12
[English](2026-08-12-collapsed-sidebar-shared-entry-motion.md) | 中文
## Problem
收起侧栏轨道的四个上方控件由两个包渲染:外壳持有侧栏切换与新建会话,Workspace 区域持有添加和搜索。它们的透明度时序相同,但几何行为不同。右对齐控件会随栏变窄而移动,左对齐控件则保持不动,因此添加即使使用相同淡入,视觉上仍比搜索慢。
底部设置控件承担不同角色。它固定在轨道页脚,不能参与上方控件的横向进入。
## Decision
轨道落位时,四个 36px 上方控件从同一个左对齐布局开始,共用一段 `150ms` 动画,从 `translateX(49px)` 移动到最终 10px 内边距。外壳把位移分别应用于侧栏切换、新建会话,并只对 Workspace 区域应用一次,因此添加与搜索会继承同一路径,不产生嵌套变换。透明度使用同一条动画时间线。
设置控件使用时长与缓动相同、但只改变透明度的独立关键帧。页面初始即为收起状态时不会播放进入动画;减少动态效果模式会禁用两段关键帧。
## Alternatives considered
**把每个轨道控件固定在最终内边距。** 这能消除不一致,但也会移除四个上方控件所需的横向进入效果。
**分别为每个 Workspace 按钮添加动画。** 这会在 `ui-workspace` 中重复外壳时序,还可能同时应用区域与子控件变换。只移动一次已注册区域,可以让动画继续由侧栏外壳持有。
**让设置控件随上方控件一起移动。** 不予采纳,因为设置是固定在底部的页脚操作,不属于上方控件序列。
## Consequences
- 侧栏切换、新建会话、添加与搜索在整个收起过程中使用相同横坐标。
- 设置在最终横坐标上淡入。
- 静态收起渲染保持最终几何,不播放启动动画。
- 样式测试固定共用动画分配、位移距离、基础锚点与设置例外。
+3
View File
@@ -97,6 +97,9 @@
"bug-fix/2026-08-10-web-favicon-dark-mode.i18n.yaml": "sha256:859c4399f9a017a68ba89552fdafa05e73c0599d94cee9551c84ea5b749a14f3", "bug-fix/2026-08-10-web-favicon-dark-mode.i18n.yaml": "sha256:859c4399f9a017a68ba89552fdafa05e73c0599d94cee9551c84ea5b749a14f3",
"bug-fix/2026-08-10-web-favicon-dark-mode.md": "sha256:4d17e247abd76ae3aed5fb4e075fd66a2838292f89f7021c82a79fe37ed905e6", "bug-fix/2026-08-10-web-favicon-dark-mode.md": "sha256:4d17e247abd76ae3aed5fb4e075fd66a2838292f89f7021c82a79fe37ed905e6",
"bug-fix/2026-08-10-web-favicon-dark-mode.zh.md": "sha256:7bbff8a3b7061c127afcc75cd2a8043b02a999b78c0180edd8f7e4807fcfe71d", "bug-fix/2026-08-10-web-favicon-dark-mode.zh.md": "sha256:7bbff8a3b7061c127afcc75cd2a8043b02a999b78c0180edd8f7e4807fcfe71d",
"bug-fix/2026-08-12-collapsed-sidebar-shared-entry-motion.i18n.yaml": "sha256:3ce4f6e39e173fc304bf64deca9c95bcddc1dbb492e065ca8c267a7a40788588",
"bug-fix/2026-08-12-collapsed-sidebar-shared-entry-motion.md": "sha256:7b169aa4543edfc965de5a8b7b9e60aa9d9d5218693cd0b57908e2d482280723",
"bug-fix/2026-08-12-collapsed-sidebar-shared-entry-motion.zh.md": "sha256:88db36c698800bf55c3c7531d6f92665576d978c29c15ff7d74215fb93376cb1",
"feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918", "feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918",
"feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b", "feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b",
"feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69", "feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69",
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md
2026-08-04-composer-tab-gutter-reservation.md: 3b28c35c1f11676e41cabde76d1b0d16c688f034 2026-08-04-composer-tab-gutter-reservation.md: 8bd9fb2d86982d82b44a82c55b7303fcd9a5bf4d
2026-08-04-composer-tab-gutter-reservation.zh.md: c357dd06c52a834c18d2e8a25246d4ad003db548 2026-08-04-composer-tab-gutter-reservation.zh.md: 4b70aeb1d3777384907c345968971fcf75b3e74d
@@ -14,13 +14,11 @@ So for as long as the transcript overflowed — the ordinary state of any sessio
## Decision ## Decision
`.scrollBody` declares `scrollbar-gutter: stable` unconditionally, and the overlay branch declares the same box a scroll container on both axes — `overflow-x: hidden; overflow-y: auto` — instead of `overflow: hidden`. `.scrollBody` declares `scrollbar-gutter: stable` for the Chat state, and the overlay branch overrides it with `scrollbar-gutter: auto` while staying a scroll container on both axes — `overflow-x: hidden; overflow-y: auto`. The reservation is Chat's alone: it holds the seat's content box at the same width whether or not the transcript overflows, so the card never jumps as a growing transcript starts to scroll, nor between the hero phase and the first scrolling turn. The overlay branch reserves nothing — the view owns its own scrollers, so a gutter there would only narrow the view's content — and its seat compensates for the bar instead ([the seat-width compensation](2026-08-12-composer-overlay-seat-width-compensation.md)).
The two halves are one change. The reservation is what makes both states measure against the same width; declaring the overlay branch a scroll container is what makes the reservation reach it. `stable` rather than `auto` because `auto` reserves only while the box actually overflows, and the difference between overflowing and not is precisely the difference between the two tabs — an `auto` gutter would state the bug rather than fix it. `stable` rather than `auto` because `auto` reserves only while the box actually overflows, and the difference between overflowing and not is precisely the difference between Chat's two phases — an `auto` gutter would state the bug rather than fix it.
The overlay state is a scroll container that nothing scrolls: the view fills it (`flex: 1 1 0` with its own clip) and the seat is out of flow, so no gesture and no clipping behavior changes. What changes is which declarations the engine honours. WebKit applies `scrollbar-gutter` to an `overflow-y: auto` box and ignores it on a hidden one — measured on this app's own composer layers and recorded in [the composer scrollport note](2026-07-31-composer-text-layers-share-one-scrollport.md) — so a reservation left on a hidden box would hold in Chromium and silently not in Safari. The reservation lives on an `overflow-y: auto` box, and that form is load-bearing: WebKit applies `scrollbar-gutter` to an `overflow-y: auto` box and ignores it on a hidden one — measured on this app's own composer layers and recorded in [the composer scrollport note](2026-07-31-composer-text-layers-share-one-scrollport.md) — so a reservation on a hidden box would hold in Chromium and silently not in Safari. The overlay branch keeps its `overflow-y: auto` form too, as a clipping box nothing scrolls out of: a single-axis scroller computes the other axis to `auto`, so the horizontal axis is declared `hidden` rather than left to compute, and would otherwise grow a horizontal scrollbar of its own the first time a view's content reached past the column.
The horizontal axis is declared rather than left to compute: a box that scrolls on one axis computes `visible` on the other to `auto`, and would grow a horizontal scrollbar of its own the first time a view's content reached past the column.
The reservation is worth what it costs only because the bar takes layout space here at all, which is not the browser's default behavior but this client's: `::-webkit-scrollbar` carries a width in ui-theme's sheet ([themed scrollbars](2026-07-28-themed-scrollbars-and-reserved-gutter.md)), and the sidebar's session list already reserves its own gutter for the same reason. The reservation is worth what it costs only because the bar takes layout space here at all, which is not the browser's default behavior but this client's: `::-webkit-scrollbar` carries a width in ui-theme's sheet ([themed scrollbars](2026-07-28-themed-scrollbars-and-reserved-gutter.md)), and the sidebar's session list already reserves its own gutter for the same reason.
@@ -37,7 +35,7 @@ The reservation is worth what it costs only because the bar takes layout space h
## Consequences ## Consequences
- Chat's content column is permanently 8px narrower — in the hero phase and while the transcript is short as well, where no bar is drawn. That is the trade: one card position at every content height, instead of the widest possible column. - Chat's content column is permanently 8px narrower — in the hero phase and while the transcript is short as well, where no bar is drawn. That is the trade: one card position at every content height, instead of the widest possible column.
- The fix covers three transitions with one declaration, because all three are the same difference: Chat ↔ Trajectory, short ↔ scrolling transcript within Chat, and hero ↔ first scrolling turn. - The card holds one position across three transitions, by two mechanisms: the reservation keeps Chat's seat at one width across its own phases (short ↔ scrolling transcript, hero ↔ first scrolling turn), and the overlay seat's compensation matches it on the Chat ↔ Trajectory transition ([the seat-width compensation](2026-08-12-composer-overlay-seat-width-compensation.md)).
- The overlay state is now a scroll container. Nothing in it can overflow today; a future view that let its content exceed the column would scroll this box instead of clipping, and would need its own clip the way the Trajectory view already has one. - The overlay state is now a scroll container. Nothing in it can overflow today; a future view that let its content exceed the column would scroll this box instead of clipping, and would need its own clip the way the Trajectory view already has one.
- The committed golden records the reserved band, so a change to the sheet's `::-webkit-scrollbar` width — the value that decides how wide the reservation is — arrives as a reviewable diff in this scenario as well as in the sidebar's. - The committed golden records the reserved band, so a change to the sheet's `::-webkit-scrollbar` width — the value that decides how wide the reservation is — arrives as a reviewable diff in this scenario as well as in the sidebar's.
@@ -45,6 +43,6 @@ The reservation is worth what it costs only because the bar takes layout space h
`apps/web/tests/composer-tab-geometry.e2e.ts` measures the input card's rectangle in both tabs, at a viewport where the card sits at its width cap and one where it shrinks with the column, and asserts the two rectangles are the same rectangle. Only a real engine reports this: jsdom gives every element a zero-sized box and no scrollbar, so a unit spec could assert the declarations exist but not that the two states land in the same place. For the same reason no CSS-text spec accompanies it — it would restate the declarations without adding a fact the browser lane does not already establish. `apps/web/tests/composer-tab-geometry.e2e.ts` measures the input card's rectangle in both tabs, at a viewport where the card sits at its width cap and one where it shrinks with the column, and asserts the two rectangles are the same rectangle. Only a real engine reports this: jsdom gives every element a zero-sized box and no scrollbar, so a unit spec could assert the declarations exist but not that the two states land in the same place. For the same reason no CSS-text spec accompanies it — it would restate the declarations without adding a fact the browser lane does not already establish.
The scenario launches chromium without Playwright's default `--hide-scrollbars`, which is load-bearing: under that argument a bar consumes no layout width, both tabs agree before this change as much as after it, and every comparison in the file holds vacuously. Measured, the pre-fix cascade leaves both bands at 0 under the argument, and at 8 and 0 with it dropped. The scenario launches chromium without Playwright's default `--hide-scrollbars`, which is load-bearing: under that argument a bar consumes no layout width, so the tabs agree with and without the compensation and every comparison in the file holds vacuously. Measured, both bands sit at 0 under the argument and at 8 and 0 with it dropped.
The pre-fix cascade is then applied in the page — `scrollbar-gutter: auto` on the scroller, `overflow: hidden` on the overlay branch — and the same two tabs measured through it, which is what separates a card that does not move from a tab switch that never reached the layout. It reproduces the reported symptom as a number: 4px on each edge, half the 8px band. The golden records that control beside the fixed state, so the fixture carries the difference the change removes rather than only its absence. The uncompensated cascade is then applied in the page — the overlay seat's `right` compensation dropped to 0 via `!important`, Chat's reservation untouched — and the same two tabs measured through it, which is what separates a card that does not move from a tab switch that never reached the layout. It reproduces the reported symptom as a number: 4px on each edge, half the 8px band. The golden records that control beside the fixed state, so the fixture carries the difference the change removes rather than only its absence.
@@ -14,13 +14,11 @@ composer 座位在组件树中只有一个节点、一个位置,但它究竟
## 决策 ## 决策
`.scrollBody` 无条件声明 `scrollbar-gutter: stable`overlay 分支则把同一个盒子在两个轴向上都声明为滚动容器——`overflow-x: hidden; overflow-y: auto`——而不再是 `overflow: hidden` `.scrollBody` 为 Chat 状态声明 `scrollbar-gutter: stable`覆盖分支则将其覆盖为 `scrollbar-gutter: auto`,同时保持为双轴滚动容器——`overflow-x: hidden; overflow-y: auto`。这条预留只属于 Chat:它让座位的内容盒在 transcript 是否溢出时都保持同一宽度,因此卡片不会在 transcript 增长到开始滚动的那一刻跳动,也不会在 hero 态与第一个可滚动轮次之间跳动。覆盖分支不预留任何槽位——视图自己滚动,槽位只会白白收窄视图内容——它的座位改为补偿滚动条宽度([座位宽度补偿](2026-08-12-composer-overlay-seat-width-compensation.md)
这两半是同一处改动。预留使两种状态依附于同一个宽度;把 overlay 分支声明为滚动容器,才使这条预留真正抵达它。`stable` 而非 `auto`,是因为 `auto` 只在盒子确实溢出时才预留,而「溢出与否」恰恰就是两个标签页之间的那点差别——`auto` 的写法只是把缺陷重述一遍,并不能修掉它。 `stable` 而非 `auto`,是因为 `auto` 只在盒子确实溢出时才预留,而「溢出与否」恰恰就是 Chat 两种相位之间的那点差别——`auto` 的写法只是把缺陷重述一遍,并不能修掉它。
overlay 状态是一个没有任何东西会去滚动它的滚动容器:视图把它填满(`flex: 1 1 0`,且自带裁剪),座位不在常规流中,因此没有任何手势与裁剪行为发生变化。变化的是引擎会认哪些声明。WebKit 对 `overflow-y: auto` 的盒子应用 `scrollbar-gutter`,对 hidden 的盒子则忽略它——这是在本应用 composer 自身的图层上实测所得,并记录于 [composer 滚动视口记录](2026-07-31-composer-text-layers-share-one-scrollport.md)——所以把预留留在一个 hidden 盒子上,会在 Chromium 上成立,在 Safari 上悄无声息地不成立。 这条预留位于 `overflow-y: auto` 的盒子上,而这个形式是承重的:WebKit 对 `overflow-y: auto` 的盒子应用 `scrollbar-gutter`,对 hidden 的盒子则忽略它——这是在本应用 composer 自身的图层上实测所得,并记录于 [composer 滚动视口记录](2026-07-31-composer-text-layers-share-one-scrollport.md)——所以把预留放在 hidden 盒子上,会在 Chromium 上成立,在 Safari 上悄无声息地不成立。覆盖分支同样保留 `overflow-y: auto` 的形式,作为没有任何内容会滚出去的裁剪盒:单轴滚动的盒子会把另一轴的 `visible` 计算为 `auto`,因此横向轴显式声明为 `hidden` 而不是交给推导,否则某个视图的内容第一次伸出列外时,它就会长出自己的横向滚动条。
横向轴是显式声明的,而不是交给推导:单轴滚动的盒子会把另一轴的 `visible` 计算为 `auto`,于是只要某个视图的内容第一次伸出列外,它就会长出自己的横向滚动条。
这条预留之所以值回它的代价,前提是滚动条在这里确实占布局空间——这并非浏览器的默认行为,而是本客户端的选择:ui-theme 的样式表给 `::-webkit-scrollbar` 声明了宽度([滚动条主题化](2026-07-28-themed-scrollbars-and-reserved-gutter.md)),侧边栏的会话列表也正是出于同一原因预留了自己的滚动条槽。 这条预留之所以值回它的代价,前提是滚动条在这里确实占布局空间——这并非浏览器的默认行为,而是本客户端的选择:ui-theme 的样式表给 `::-webkit-scrollbar` 声明了宽度([滚动条主题化](2026-07-28-themed-scrollbars-and-reserved-gutter.md)),侧边栏的会话列表也正是出于同一原因预留了自己的滚动条槽。
@@ -37,7 +35,7 @@ overlay 状态是一个没有任何东西会去滚动它的滚动容器:视图
## 后果 ## 后果
- Chat 的内容列永久变窄 8px——hero 态与 transcript 尚短、根本不绘制滚动条时同样如此。这就是这笔交易:以最宽的列换取卡片在任何内容高度下都只有一个位置。 - Chat 的内容列永久变窄 8px——hero 态与 transcript 尚短、根本不绘制滚动条时同样如此。这就是这笔交易:以最宽的列换取卡片在任何内容高度下都只有一个位置。
- 一条声明覆盖三种切换,因为这三者本就是同一个差异:Chat ↔ Trajectory、Chat 内部 transcript 较短 ↔ transcript 可滚动,以及 hero ↔ 第一个可滚动轮次 - 卡片在三种切换下保持同一位置,由两种机制达成:预留让 Chat 的座位在自身各相位间保持同一宽度(transcript 较短 ↔ 可滚动、hero ↔ 第一个可滚动轮次),Chat ↔ Trajectory 的切换则由覆盖座位的补偿来对齐([座位宽度补偿](2026-08-12-composer-overlay-seat-width-compensation.md)
- overlay 状态现在是一个滚动容器。今天其中没有任何内容会溢出;将来若有视图允许自身内容超出会话列,这个盒子会滚动而不是裁剪,那个视图就需要像 Trajectory 视图那样自带裁剪。 - overlay 状态现在是一个滚动容器。今天其中没有任何内容会溢出;将来若有视图允许自身内容超出会话列,这个盒子会滚动而不是裁剪,那个视图就需要像 Trajectory 视图那样自带裁剪。
- 提交的 golden 记录了预留条带,因此样式表中 `::-webkit-scrollbar` 宽度的变化——决定这条预留有多宽的那个值——会在本场景中与在侧边栏场景中一样,以可评审的 diff 形式出现。 - 提交的 golden 记录了预留条带,因此样式表中 `::-webkit-scrollbar` 宽度的变化——决定这条预留有多宽的那个值——会在本场景中与在侧边栏场景中一样,以可评审的 diff 形式出现。
@@ -45,6 +43,6 @@ overlay 状态是一个没有任何东西会去滚动它的滚动容器:视图
`apps/web/tests/composer-tab-geometry.e2e.ts` 在两个标签页下测量输入卡片的矩形,分别取卡片处于宽度上限的视口与卡片随列收缩的视口,并断言这两个矩形是同一个矩形。只有真实引擎能报告这件事:jsdom 给每个元素的盒子尺寸都是零,也没有滚动条,因此单元测试只能断言那些声明存在,无法断言两种状态落在同一位置。出于同一原因,本次没有附带读取 CSS 文本的单元测试——它只会把声明复述一遍,并不会补上浏览器车道尚未确立的事实。 `apps/web/tests/composer-tab-geometry.e2e.ts` 在两个标签页下测量输入卡片的矩形,分别取卡片处于宽度上限的视口与卡片随列收缩的视口,并断言这两个矩形是同一个矩形。只有真实引擎能报告这件事:jsdom 给每个元素的盒子尺寸都是零,也没有滚动条,因此单元测试只能断言那些声明存在,无法断言两种状态落在同一位置。出于同一原因,本次没有附带读取 CSS 文本的单元测试——它只会把声明复述一遍,并不会补上浏览器车道尚未确立的事实。
该场景启动 chromium 时去掉了 Playwright 默认的 `--hide-scrollbars`,这一点是承重的:带上该参数时滚动条不占任何布局宽度,两个标签页在改动前后同样一致,文件中的每一处比较都会空洞地通过。实测:带上该参数时,改动前的层叠让两条预留带的宽度都是 0;去掉它则分别是 8 与 0。 该场景启动 chromium 时去掉了 Playwright 默认的 `--hide-scrollbars`,这一点是承重的:带上该参数时滚动条不占任何布局宽度,因此两个标签页在有补偿与无补偿时同样一致,文件中的每一处比较都会空洞地通过。实测:带上该参数时两条预留带的宽度都是 0;去掉它则分别是 8 与 0。
随后,改动前的层叠会被注入页面——滚动容器上 `scrollbar-gutter: auto`overlay 分支上 `overflow: hidden`——并在其下测量同样的两个标签页,这正是把「卡片确实没动」与「标签页切换根本没到达布局」区分开的那一步。它把上报的症状复现为一个数字:每条边 4px,恰是 8px 带宽的一半。golden 把这份对照与修复后的状态并排记录,因此 fixture(测试前置数据)承载的是这次改动所消除的那个差值,而不仅仅是它的缺席。 随后,未补偿的级联会被注入页面——通过 `!important` 把覆盖座位的 `right` 补偿降为 0,Chat 的预留保持不变——并在其下测量同样的两个标签页,这正是把「卡片确实没动」与「标签页切换根本没到达布局」区分开的那一步。它把上报的症状复现为一个数字:每条边 4px,恰是 8px 带宽的一半。golden 把这份对照与修复后的状态并排记录,因此 fixture(测试前置数据)承载的是这次改动所消除的那个差值,而不仅仅是它的缺席。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-12-composer-overlay-seat-width-compensation.md
2026-08-12-composer-overlay-seat-width-compensation.md: 0ec4d1272ac1adab5b724dccf44f567e15cc3368
2026-08-12-composer-overlay-seat-width-compensation.zh.md: 2f66771240fe62e15c481342d55a82dd09cfb864
@@ -0,0 +1,39 @@
# Agent Note: The overlay composer seat compensates for the bar instead of reserving a gutter
Status: implemented
English | [中文](2026-08-12-composer-overlay-seat-width-compensation.zh.md)
## Problem
The [composer-tab gutter reservation](2026-08-04-composer-tab-gutter-reservation.md) made the column's scroller reserve a scrollbar gutter unconditionally, so the composer seat measured the same width in Chat and in a view with a composer overlay. The cost was paid by every overlay view: the view's content column ended 8px short of the column's right edge, because the scroller reserved a gutter for a bar it never draws — the trajectory ledger owns its own scrollers and the outer box never scrolls.
The trajectory table made that cost visible: its full-width row divider lines stopped 8px short of the pane edge, leaving a strip of whitespace at the right of every line and of the whole content column.
## Decision
The reservation now belongs to Chat alone. The overlay branch declares `scrollbar-gutter: auto`, so the view's content spans the full column; the overlay composer seat (absolutely positioned against the padding box) gives back the bar's width with `right: var(--dsh-scrollbar-width)`, so the input card still measures the same width as Chat's seat and does not move between tabs.
The compensation value is not a literal: ui-theme's scrollbar.css defines `--dsh-scrollbar-width` (8px on the WebKit path) beside the `::-webkit-scrollbar` rule it mirrors, and the seat reads that variable. The scrollbar-styles spec pairs the variable with the mirrored rule and with the compensation consumer, so a change to the sheet's bar width without the variable — or to the variable without the consumer — fails the gate, not just review.
## Alternatives considered
**Keep the unconditional reservation and shrink every overlay view.** The pre-fix behavior. It keeps one declaration for both tabs but taxes every overlay view with an 8px content column, which the trajectory ledger surfaced as visible whitespace. Rejected because the overlay views own their scrolling; they should not pay for Chat's bar.
**Reserve on the overlay branch too and let the view bleed into the gutter.** More moving parts for the same result: the gutter would still exist on a box that never scrolls, and the view would have to break out of the content box to reclaim its width.
**Accept the 4px card shift.** Dropping the reservation without compensating the seat would move the input card on every tab switch, which is exactly the symptom the earlier note fixed. Rejected: the card position is a deliberate cross-tab invariant.
**Inset the overlay seat by the bar's width.** The [gutter-reservation note](2026-08-04-composer-tab-gutter-reservation.md) rejected exactly this, and this note adopts it; what changed is the rejection's premise. The number was the engine's, not ours — the WebKit path draws the sheet's 8px bar while the Firefox path draws whatever `scrollbar-width: thin` resolves to — so a hardcoded inset would line the two states up in Chromium and drift elsewhere. The overlay branch reserved an engine-resolved gutter of its own back then, so an inset had to match that width exactly. Today the overlay branch reserves nothing, so the compensation is the overlay side's only mechanism, and the literal half of the rejection is answered by making the 8px a variable that mirrors the `::-webkit-scrollbar` rule in the same diff. The Firefox half remains: Chat reserves the engine-resolved width while the compensation stays fixed, and the residual drift where the two differ is recorded as an accepted cost in Consequences.
## Consequences
- Chat keeps its reserved gutter and its stable card position; nothing changes on that tab.
- Overlay views (trajectory) span the full column; the trajectory ledger's divider lines reach the pane edge.
- The input card still holds one horizontal position across the Chat and Trajectory tabs, now by two mechanisms instead of one: Chat reserves, the overlay seat compensates.
- Chat reserves the engine-resolved width while the overlay seat compensates a fixed 8px. Where the two differ — the Firefox path resolves `scrollbar-width: thin` per platform, and the e2e runs only on Chromium — the card drifts by half the difference on tab switch. Accepted residual cost, recorded here rather than asserted away: no measurement of the Firefox thin width on the target platforms exists in this change.
- `--dsh-scrollbar-width` becomes a public ui-theme variable read outside ui-theme; the scrollbar-styles spec pairs it with the mirrored `::-webkit-scrollbar` width rule and with the compensation consumer, closing the indirection-gate gap the variable would otherwise leave.
## Testing
`apps/web/tests/composer-tab-geometry.e2e.ts` still asserts the card holds its position across tabs and now also asserts the split: Chat's scroller keeps `scrollbar-gutter: stable` and a nonzero band, while the overlay branch resolves `auto` with a zero band. The control cascade changed with the mechanism: it now drops the seat's `right` compensation (instead of dropping a gutter Chat never had on that branch) and measures the same 4px shift, proving the equal rectangles are not a tab switch that never reached layout. The committed golden records both states.
@@ -0,0 +1,39 @@
# Agent Note: 覆盖视图的 composer 座位改为补偿滚动条宽度,不再预留滚动条槽
Status: implemented
[English](2026-08-12-composer-overlay-seat-width-compensation.md) | 中文
## 问题
[composer 标签页滚动条槽预留](2026-08-04-composer-tab-gutter-reservation.md) 让会话列滚动容器无条件预留一条滚动条槽,使 composer 座位在 Chat 与带 composer 覆盖的视图中测得相同宽度。代价由每个覆盖视图承担:视图内容列比列右边缘窄 8px,因为滚动容器为一条它从不绘制的滚动条预留了槽——trajectory 台账由视图内部自己的滚动容器滚动,外层盒子从不滚动。
trajectory 表格让这个代价显形:整行分隔线在面板右边缘前 8px 处停止,每条线右侧以及整个内容列右侧都留下一条空白带。
## 决策
预留现在只属于 Chat。覆盖分支声明 `scrollbar-gutter: auto`,视图内容占满整列;覆盖分支的 composer 座位(相对 padding box 绝对定位)用 `right: var(--dsh-scrollbar-width)` 让出滚动条宽度,使输入卡仍与 Chat 座位测得相同宽度,切换标签页时不移动。
补偿值不是字面量:ui-theme 的 scrollbar.css 在它镜像的 `::-webkit-scrollbar` 规则旁定义 `--dsh-scrollbar-width`(WebKit 路径 8px),座位读取该变量。scrollbar-styles 规格把该变量与其镜像规则、以及补偿消费者配对检查,因此样式表滚动条宽度一变却不同步变量——或变量一变却不同步消费者——都会让门禁失败,而不只是评审时发现。
## 备选方案
**保留无条件预留,压缩每个覆盖视图。** 修复前行为。两个标签页一条声明,但每个覆盖视图都要付出 8px 内容列,trajectory 台账将其显现为可见空白。已拒绝:覆盖视图自己滚动,不应为 Chat 的滚动条买单。
**覆盖分支也预留,并让视图渗入滚动条槽。** 同样结果下更多活动部件:从不滚动的盒子上仍存在滚动条槽,视图还得突破内容盒才能取回宽度。
**接受 4px 卡片位移。** 去掉预留却不补偿座位,会在每次切换标签页时移动输入卡——正是前一份 note 修复的症状。已拒绝:卡片位置是刻意保持的跨标签页不变量。
**把 overlay 座位按滚动条宽度内缩。** [滚动条槽预留 note](2026-08-04-composer-tab-gutter-reservation.md) 当初否决的正是这个方案,本 note 采纳了它;变的是否决的前提。这个数字属于引擎而不属于我们——WebKit 路径绘制样式表里的 8px 滚动条,Firefox 路径绘制 `scrollbar-width: thin` 解析出的宽度——因此硬编码的内缩会让两种状态在 Chromium 上对齐、在别处继续漂移。当初 overlay 分支自己预留的是引擎解析出的槽宽,内缩必须精确匹配那个宽度。如今 overlay 分支不预留任何槽位,补偿成为覆盖侧唯一的机制;否决的字面量那一半,通过把 8px 变成与 `::-webkit-scrollbar` 规则同处一个 diff 的变量来回应。Firefox 那一半仍然存在:Chat 预留引擎解析宽度,补偿保持固定 8px,两者不等之处的残余漂移作为接受的代价记录在后果中。
## 后果
- Chat 保留滚动条槽与稳定的卡片位置;该标签页无任何变化。
- 覆盖视图(trajectory)占满整列;trajectory 台账的分隔线到达面板右边缘。
- 输入卡在 Chat 与 Trajectory 标签页间仍保持同一水平位置,现在由两种机制而非一种达成:Chat 预留,覆盖座位补偿。
- Chat 预留引擎解析宽度,覆盖座位补偿固定的 8px。两者不等之处——Firefox 路径按平台解析 `scrollbar-width: thin`,而 e2e 只在 Chromium 上运行——卡片在切换标签页时会漂移半个差值。这是接受的残余代价,如实记录于此而不断言消除:本次改动并未提供目标平台 Firefox thin 宽度的实测。
- `--dsh-scrollbar-width` 成为 ui-theme 对外、且被 ui-theme 之外读取的变量;scrollbar-styles 规格把它与镜像的 `::-webkit-scrollbar` 宽度规则、以及补偿消费者配对检查,补上了该变量本会留下的间接层门禁缺口。
## 测试
`apps/web/tests/composer-tab-geometry.e2e.ts` 仍断言输入卡在标签页间保持位置,并新增断言拆分:Chat 滚动容器保持 `scrollbar-gutter: stable` 与非零槽宽,覆盖分支解析为 `auto` 且槽宽为零。控制级联随机制改变:现在移除座位的 `right` 补偿(而非移除该分支上 Chat 从未有过的槽),测得同样的 4px 位移,证明相等的矩形并非从未到达布局的标签页切换。提交的 golden 记录两种状态。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md
2026-07-27-trajectory-inspection-ledger.md: 74ed1f8ec6f6efcbf77e9caec7e254cb114efbd9 2026-07-27-trajectory-inspection-ledger.md: 7f97bc395ed694a0a750d3b744cc9887834ef32f
2026-07-27-trajectory-inspection-ledger.zh.md: be648cb73478037979fd941753129b7c68be5085 2026-07-27-trajectory-inspection-ledger.zh.md: 34bb8741124c410fe06ec18bdde801caa987f4fb
@@ -21,7 +21,7 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested
- Call schemas come from the active recorded Request header. Keyless snapshot fixtures deliberately replace that catalog with the non-array `{{tools}}` token, which the durable inspection boundary treats as unavailable instead of attempting to project or fabricate schemas. - Call schemas come from the active recorded Request header. Keyless snapshot fixtures deliberately replace that catalog with the non-array `{{tools}}` token, which the durable inspection boundary treats as unavailable instead of attempting to project or fabricate schemas.
- Selecting a record or Request opens an inspector inside Trajectory. Tabs and Summary sections follow the selected entity: Markdown messages expose rendered content, source fields, provider/model fields, and hierarchy views; tools add JSON payload/result and schema views; Requests add options, usage, timing, and result navigation. Scrollable Summary regions keep their scrollbar thumbs transparent until hover or `focus-within`, while retaining the scrollbar reservation and scroll behavior. Images render as media rather than serialized data. - Selecting a record or Request opens an inspector inside Trajectory. Tabs and Summary sections follow the selected entity: Markdown messages expose rendered content, source fields, provider/model fields, and hierarchy views; tools add JSON payload/result and schema views; Requests add options, usage, timing, and result navigation. Scrollable Summary regions keep their scrollbar thumbs transparent until hover or `focus-within`, while retaining the scrollbar reservation and scroll behavior. Images render as media rather than serialized data.
- Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels. - Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels.
- A long ledger initially positions the loaded tail at the bottom and mounts only the viewport's row window plus bounded overscan. Request-only separators join the next measurable virtual item, with a terminal separator retaining its own fixed clearance, so the virtualizer never owns a zero-height item. Semantic DOM-safe row keys and ARIA indexes expose identity independently from mount position. A tail with known older history virtualizes immediately even when its loaded projection is below the ordinary row threshold. Stable-key virtualizer anchoring preserves the visible item across prepends and appends; the manual scroll-height fallback applies only when completing pagination disables virtualization. Selection, timeline focus, folding, search, and bottom following address records by stable event or tool-call identity rather than requiring their DOM rows to exist. An explicit loading row covers records until initial positioning finishes and while an older Session page is pending. - A long ledger initially positions the loaded tail at the bottom and mounts only the viewport's row window plus bounded overscan. Request-only separators join the next measurable virtual item, with a terminal separator retaining its own fixed clearance, so the virtualizer never owns a zero-height item. Semantic DOM-safe row keys and ARIA indexes expose identity independently from mount position. A tail with known older history virtualizes immediately even when its loaded projection is below the ordinary row threshold. Stable-key virtualizer anchoring preserves the visible item across prepends and appends; the manual scroll-height fallback applies only when completing pagination disables virtualization. Selection, timeline focus, folding, search, and bottom following address records by stable event or tool-call identity rather than requiring their DOM rows to exist. An explicit loading row covers records until initial positioning finishes. While an older Session prefix remains unloaded, an interactive first row precedes the loaded records and requests one older page; the same row becomes a disabled loading status for a pending page and disappears only when paging completes.
- The separate Waterfall tab is removed. A fixed Overview above the ledger projects every loaded record with known `startedAt` onto three semantic timing lanes using its own duration. While an older prefix remains unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control covers the truncated edge and loads one earlier page without assigning unknown history a fabricated duration; hovering that control suppresses the ordinary timeline cursor. Finalized Assistant spans divide the recorded interval at the first non-empty token delta, so distinct TTFT and decoding colors retain their actual ratio; incomplete timing falls back to one Assistant color. Hovering for 500 ms exposes exact start/end, total duration, TTFT, and decoding time without relying on the browser's native tooltip delay. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the interval selection; dragging instead pans an already zoomed viewport without mutating it. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation. - The separate Waterfall tab is removed. A fixed Overview above the ledger projects every loaded record with known `startedAt` onto three semantic timing lanes using its own duration. While an older prefix remains unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control covers the truncated edge and loads one earlier page without assigning unknown history a fabricated duration; hovering that control suppresses the ordinary timeline cursor. Finalized Assistant spans divide the recorded interval at the first non-empty token delta, so distinct TTFT and decoding colors retain their actual ratio; incomplete timing falls back to one Assistant color. Hovering for 500 ms exposes exact start/end, total duration, TTFT, and decoding time without relying on the browser's native tooltip delay. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the interval selection; dragging instead pans an already zoomed viewport without mutating it. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation.
- Live history updates retain the ledger's bottom position only while the user is already following its tail. Scrolling upward clears that follow state, so streamed chunks and newly appended records do not interrupt inspection of earlier rows. Tail following and virtualizer measurement react to row keys and heights rather than content identity, so text-only stream frames neither discard the measurement cache nor repeat a DOM scroll write. - Live history updates retain the ledger's bottom position only while the user is already following its tail. Scrolling upward clears that follow state, so streamed chunks and newly appended records do not interrupt inspection of earlier rows. Tail following and virtualizer measurement react to row keys and heights rather than content identity, so text-only stream frames neither discard the measurement cache nor repeat a DOM scroll write.
- Token streaming updates only the matching Trajectory Assistant Context, while publication is coalesced to at most once per animation frame. The target snapshot preserves the existing stage, layout, Request numbering, Overview, and search inputs; completed Assistant State retains assembled blocks, timing, and usage rather than every raw chunk payload, while Session keeps the raw Event window. - Token streaming updates only the matching Trajectory Assistant Context, while publication is coalesced to at most once per animation frame. The target snapshot preserves the existing stage, layout, Request numbering, Overview, and search inputs; completed Assistant State retains assembled blocks, timing, and usage rather than every raw chunk payload, while Session keeps the raw Event window.
@@ -21,7 +21,7 @@ Status: implemented
- 调用 schema 来自当前生效且已记录的请求头。无密钥快照 fixture(测试前置数据)有意将该目录替换为非数组 token `{{tools}}`,持久化检查边界会将其视为不可用,而不是尝试投影或虚构 schema。 - 调用 schema 来自当前生效且已记录的请求头。无密钥快照 fixture(测试前置数据)有意将该目录替换为非数组 token `{{tools}}`,持久化检查边界会将其视为不可用,而不是尝试投影或虚构 schema。
- 选择记录或请求后,Trajectory 内部会打开检查器,其标签页和概述区域随实体类型变化:Markdown 消息提供渲染内容、来源字段、提供方/模型字段和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。可滚动的概述区域默认保持滚动条滑块透明,直到悬停或 `focus-within` 时才显示,同时保留滚动条预留空间和滚动行为。图片以媒体形式渲染,而不是显示为序列化数据。 - 选择记录或请求后,Trajectory 内部会打开检查器,其标签页和概述区域随实体类型变化:Markdown 消息提供渲染内容、来源字段、提供方/模型字段和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。可滚动的概述区域默认保持滚动条滑块透明,直到悬停或 `focus-within` 时才显示,同时保留滚动条预留空间和滚动行为。图片以媒体形式渲染,而不是显示为序列化数据。
- 折叠轮次时保留其第一条记录,并用紧凑的步骤数和工具调用数替换后续所有行;折叠助手时对其工具调用后代应用相同操作。全局控件会折叠或展开这两个层级。 - 折叠轮次时保留其第一条记录,并用紧凑的步骤数和工具调用数替换后续所有行;折叠助手时对其工具调用后代应用相同操作。全局控件会折叠或展开这两个层级。
- 长记录表初始时将已加载尾部置于底部,只挂载视口对应的行窗口及有界的额外缓冲行。仅含请求的分隔行并入下一个具备可测高度的虚拟项,末尾分隔行则保留固定留白,因此虚拟化器不会管理零高度项。可安全用于 DOM 的语义行键与 ARIA 索引使标识不依赖挂载位置。只要已知尾部之前仍有更早历史,即使当前已加载投影低于常规行数阈值,也会立即启用虚拟化。基于稳定键的虚拟化器锚定会在向前补页和尾部追加时保留当前可见项;只有分页完成导致虚拟化停用时,才使用手动滚动高度兜底。选择、时间线聚焦、折叠、搜索和末尾跟随均按稳定的事件或工具调用标识定位,不要求对应 DOM 行已存在。初始定位完成前以及更早 Session 页面仍在等待时,明确的加载行会遮住真实记录 - 长记录表初始时将已加载尾部置于底部,只挂载视口对应的行窗口及有界的额外缓冲行。仅含请求的分隔行并入下一个具备可测高度的虚拟项,末尾分隔行则保留固定留白,因此虚拟化器不会管理零高度项。可安全用于 DOM 的语义行键与 ARIA 索引使标识不依赖挂载位置。只要已知尾部之前仍有更早历史,即使当前已加载投影低于常规行数阈值,也会立即启用虚拟化。基于稳定键的虚拟化器锚定会在向前补页和尾部追加时保留当前可见项;只有分页完成导致虚拟化停用时,才使用手动滚动高度兜底。选择、时间线聚焦、折叠、搜索和末尾跟随均按稳定的事件或工具调用标识定位,不要求对应 DOM 行已存在。初始定位完成前,明确的加载行会遮住真实记录。更早 Session 前缀仍未加载时,交互式首行位于已加载记录之前,可请求一页更早历史;页面加载期间,同一行会变为禁用的加载状态,仅在分页完成时消失
- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的已加载记录按各自耗时投影到三条语义计时轨道。仍有更早前缀尚未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会遮住截断边缘并加载一页更早历史,而不会为未知历史虚构耗时;悬停在该控件上会隐藏普通的时间线光标。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间任一边界重叠的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。 - 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的已加载记录按各自耗时投影到三条语义计时轨道。仍有更早前缀尚未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会遮住截断边缘并加载一页更早历史,而不会为未知历史虚构耗时;悬停在该控件上会隐藏普通的时间线光标。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间任一边界重叠的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。
- 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。末尾跟随与虚拟化器测量仅响应行键和高度,而非内容标识,因此仅含文本的流式帧既不会丢弃测量缓存,也不会重复执行 DOM 滚动写入。 - 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。末尾跟随与虚拟化器测量仅响应行键和高度,而非内容标识,因此仅含文本的流式帧既不会丢弃测量缓存,也不会重复执行 DOM 滚动写入。
- token 流式输出只更新命中的 Trajectory Assistant Context,发布则合并为每个 animation frame 最多一次。target snapshot 继续提供既有 stage、layout、请求编号、Overview 与搜索输入;已完成的 Assistant State 只保留组装后的 blocks、计时与 usage,不保留每条原始 chunk payload,而 Session 继续保存原始 Event 窗口。 - token 流式输出只更新命中的 Trajectory Assistant Context,发布则合并为每个 animation frame 最多一次。target snapshot 继续提供既有 stage、layout、请求编号、Overview 与搜索输入;已完成的 Assistant State 只保留组装后的 blocks、计时与 usage,不保留每条原始 chunk payload,而 Session 继续保存原始 Event 窗口。
+54 -44
View File
@@ -11,12 +11,13 @@
// gets an absolutely positioned seat instead, laid out against the padding box, // gets an absolutely positioned seat instead, laid out against the padding box,
// which the scrollbar never reduces. // which the scrollbar never reduces.
// //
// Without a shared reservation the two tabs disagree by exactly the bar's // The column handles the two edges without reserving the gutter on both: Chat
// width for as long as the transcript overflows: the card jumps sideways on // keeps `scrollbar-gutter: stable` so its seat's content box never jumps as the
// every tab switch, and inside Chat alone at the moment a growing transcript // transcript starts to scroll; the overlay branch does NOT reserve (the view
// starts to scroll. The column reserves the gutter unconditionally // owns its own scrollers, so a reserved gutter would only narrow the view's
// (`scrollbar-gutter: stable`) and states the overlay branch as a scroll // content by the bar's width), and the overlay seat instead gives back the
// container on the same axes, so both edges are the same edge. // bar's width (`right: var(--dsh-scrollbar-width)`) so both seats measure the
// same width and the card does not move.
// //
// Only a real engine can show this. The seat's geometry is layout: jsdom gives // Only a real engine can show this. The seat's geometry is layout: jsdom gives
// every element a zero-sized box and reports no scrollbar at all, so a unit spec // every element a zero-sized box and reports no scrollbar at all, so a unit spec
@@ -27,18 +28,17 @@
// The browser is launched WITHOUT Playwright's default `--hide-scrollbars`, // The browser is launched WITHOUT Playwright's default `--hide-scrollbars`,
// which is load-bearing rather than incidental. Under that argument a scroll // which is load-bearing rather than incidental. Under that argument a scroll
// container's bar consumes no layout width at all, so the two tabs agree with // container's bar consumes no layout width at all, so the two tabs agree with
// and without the reservation and every comparison below holds vacuously — // and without the compensation and every comparison below holds vacuously —
// measured: the unreserved cascade leaves both tabs' bands at 0 there, against // measured: the uncompensated cascade leaves both tabs' bands at 0 there,
// 8 and 0 with the argument dropped. Dropping it is also the faithful // against 8 and 0 with the argument dropped. Dropping it is also the faithful
// configuration: ui-theme's scrollbar.css gives `::-webkit-scrollbar` a width, // configuration: ui-theme's scrollbar.css gives `::-webkit-scrollbar` a width,
// and a bar that occupies layout space is what the product actually draws. // and a bar that occupies layout space is what the product actually draws.
// //
// The scenario runs that unreserved cascade in the page — `scrollbar-gutter: auto` // The scenario runs that uncompensated cascade in the page — the overlay seat's
// on the scroller, `overflow: hidden` on the overlay branch — and measures the // `right` compensation dropped to 0 — and measures the same two tabs through
// same two tabs through it, which is what keeps the equal rectangles above from // it, which is what keeps the equal rectangles above from being explained by a
// being explained by a tab switch that never reached the layout. It is the // tab switch that never reached the layout. It is the reported symptom as a
// reported symptom as a number: the card moves 4px, half the 8px band, on each // number: the card moves 4px, half the 8px band, on each edge.
// edge.
// //
// Zero model calls: a seeded cold session renders from its log, and switching // Zero model calls: a seeded cold session renders from its log, and switching
// tabs asks the host for nothing. A stray stream would fail loud with NO_ADAPTER. // tabs asks the host for nothing. A stray stream would fail loud with NO_ADAPTER.
@@ -62,9 +62,9 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-tab-geometry',
* Absolute coordinates are deliberately absent: they depend on the sidebar's * Absolute coordinates are deliberately absent: they depend on the sidebar's
* laid-out width and on font metrics, so committing them would produce a fixture * laid-out width and on font metrics, so committing them would produce a fixture
* that has to be re-recorded per platform. What is recorded is the distance * that has to be re-recorded per platform. What is recorded is the distance
* between the two tabs' rectangles, which is zero when the reservation holds and * between the two tabs' rectangles, which is zero when the compensation holds and
* the bar's width when it does not — including under the control, so the golden * the bar's width when it does not — including under the control, so the golden
* carries the shift the unreserved cascade produces rather than only its absence. * carries the shift the uncompensated cascade produces rather than only its absence.
*/ */
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md') const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
const MODE = webSnapshotMode() const MODE = webSnapshotMode()
@@ -114,15 +114,15 @@ async function setMeasuredViewport(
} }
/** /**
* The unreserved cascade, injected into the page: the reservation dropped and * The uncompensated cascade, injected into the page: the overlay seat's `right`
* the overlay branch forced to a hidden box. `!important` beats the module * compensation dropped to 0, so it measures the full padding box while Chat's
* seat still rides the reserved content box. `!important` beats the module
* rules without a rebuild, and the id lets the control be lifted again in the * rules without a rebuild, and the id lets the control be lifted again in the
* same session. * same session.
*/ */
const CONTROL_STYLE_ID = 'composer-tab-geometry-control' const CONTROL_STYLE_ID = 'composer-tab-geometry-control'
const CONTROL_CSS = ` const CONTROL_CSS = `
[data-conversation-scroll] { scrollbar-gutter: auto !important; } [data-conversation-scroll]:has([data-conversation-composer-overlay]) > [data-composer-seat] { right: 0 !important; }
[data-conversation-scroll]:has([data-conversation-composer-overlay]) { overflow: hidden !important; }
` `
/** The column scroller and the input card as the browser lays them out, in one tab. */ /** The column scroller and the input card as the browser lays them out, in one tab. */
@@ -221,11 +221,13 @@ async function compareTabs(page: Page): Promise<TabComparison> {
} }
/** /**
* Run the unreserved cascade in the page for one measurement, then lift it. * Run the uncompensated cascade in the page for one measurement, then lift it:
* the overlay seat's `right` compensation dropped to 0, so it measures the
* full padding box while Chat's seat still rides the reserved content box.
* @param page - the page under test. * @param page - the page under test.
* @returns the comparison as the column lays out without the reservation. * @returns the comparison as the column lays out without the compensation.
*/ */
async function compareTabsWithoutReservation(page: Page): Promise<TabComparison> { async function compareTabsWithoutCompensation(page: Page): Promise<TabComparison> {
await page.evaluate(({ id, css }) => { await page.evaluate(({ id, css }) => {
const style = document.createElement('style') const style = document.createElement('style')
style.id = id style.id = id
@@ -268,7 +270,7 @@ async function openSeededSession(page: Page): Promise<void> {
* Render the golden body. * Render the golden body.
* @param wide - comparison at the viewport where the card sits at its width cap. * @param wide - comparison at the viewport where the card sits at its width cap.
* @param narrow - comparison at the viewport where the card shrinks with the column. * @param narrow - comparison at the viewport where the card shrinks with the column.
* @param control - comparison at the wide viewport with the reservation removed. * @param control - comparison at the wide viewport with the compensation removed.
* @returns the golden body, without a trailing newline. * @returns the golden body, without a trailing newline.
*/ */
function renderGeometry(wide: TabComparison, narrow: TabComparison, control: TabComparison): string { function renderGeometry(wide: TabComparison, narrow: TabComparison, control: TabComparison): string {
@@ -291,7 +293,7 @@ function renderGeometry(wide: TabComparison, narrow: TabComparison, control: Tab
'', '',
...section(`Wide viewport (${String(WIDE_VIEWPORT.width)}px, card at its cap)`, wide), ...section(`Wide viewport (${String(WIDE_VIEWPORT.width)}px, card at its cap)`, wide),
...section(`Narrow viewport (${String(NARROW_VIEWPORT.width)}px, card shrinking with the column)`, narrow), ...section(`Narrow viewport (${String(NARROW_VIEWPORT.width)}px, card shrinking with the column)`, narrow),
...section('Wide viewport, reservation removed in the page (control)', control), ...section('Wide viewport, seat compensation removed in the page (control)', control),
].join('\n').trimEnd() ].join('\n').trimEnd()
} }
@@ -322,22 +324,29 @@ describe('web e2e: input card position across view tabs', () => {
await scaffold?.close() await scaffold?.close()
}) })
it('reserves the same gutter in both tabs while the transcript scrolls', async () => { it('reserves the gutter in Chat and lets Trajectory own its width', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-band')) onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-band'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false) await setMeasuredViewport(page, WIDE_VIEWPORT, false)
// Vacuity guard, in two parts. A transcript that does not overflow gives // Vacuity guard. The scenario must be able to fail: on an engine that
// Chat no scrollbar, and a hidden or overlaid bar gives it no width; either // does not implement `scrollbar-gutter`, Chat reserves nothing and the
// would make the tabs agree without the reservation doing anything. // overlay seat's fixed compensation stands alone, manufacturing an 8px
// deviation the equal-rectangle assertions would catch. `stable` reserves
// even without overflow, so a short transcript is not a vacuous case; the
// poll still pins the measurement to the overflowing state the product
// ships.
await expect.poll(async () => (await measureTab(page)).scrolls, { timeout: 10_000 }).toBe(true) await expect.poll(async () => (await measureTab(page)).scrolls, { timeout: 10_000 }).toBe(true)
const comparison = await compareTabs(page) const comparison = await compareTabs(page)
expect(comparison.chat.band).toBeGreaterThan(0) expect(comparison.chat.band).toBeGreaterThan(0)
// The reservation reaches both states, which is the whole point: the same // Chat keeps the unconditional reservation so its seat's content box never
// band, on a box that scrolls and on one that only holds a view. // jumps as the transcript starts to scroll.
expect(comparison.chat.gutter).toBe('stable') expect(comparison.chat.gutter).toBe('stable')
expect(comparison.trajectory.gutter).toBe('stable') // The overlay branch does NOT reserve: the view owns its own scrollers, so
expect(comparison.trajectory.band).toBe(comparison.chat.band) // a reserved gutter would only narrow the view's content by the bar's
// width. The seat compensates instead, which the next test asserts.
expect(comparison.trajectory.gutter).toBe('auto')
expect(comparison.trajectory.band).toBe(0)
// Declared as a scroll container on both axes rather than left to compute: // Declared as a scroll container on both axes rather than left to compute:
// `overflow: hidden` would drop the reservation in WebKit, and a `visible` // `overflow: hidden` would drop any reservation in WebKit, and a `visible`
// horizontal axis computes to `auto` beside a scrolling one. // horizontal axis computes to `auto` beside a scrolling one.
expect(comparison.trajectory.overflowY).toBe('auto') expect(comparison.trajectory.overflowY).toBe('auto')
expect(comparison.trajectory.overflowX).toBe('hidden') expect(comparison.trajectory.overflowX).toBe('hidden')
@@ -351,7 +360,7 @@ describe('web e2e: input card position across view tabs', () => {
await setMeasuredViewport(page, WIDE_VIEWPORT, false) await setMeasuredViewport(page, WIDE_VIEWPORT, false)
const comparison = await compareTabs(page) const comparison = await compareTabs(page)
// The reported symptom as a number. At this viewport the card sits at its // The reported symptom as a number. At this viewport the card sits at its
// width cap, so the unreserved cascade's shift shows up as a centring // width cap, so the uncompensated cascade's shift shows up as a centring
// difference — half the band on each edge — rather than as a width change. // difference — half the band on each edge — rather than as a width change.
expect(comparison.leftShift).toBe(0) expect(comparison.leftShift).toBe(0)
expect(comparison.rightShift).toBe(0) expect(comparison.rightShift).toBe(0)
@@ -378,20 +387,21 @@ describe('web e2e: input card position across view tabs', () => {
expect(tripwire.pageErrors).toEqual([]) expect(tripwire.pageErrors).toEqual([])
}, 60_000) }, 60_000)
it('moves the card again once the reservation is removed in the page', async () => { it('moves the card again once the seat compensation is removed in the page', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-control')) onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-control'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false) await setMeasuredViewport(page, WIDE_VIEWPORT, false)
// The control: without it, equal rectangles could also mean the tab switch // The control: without it, equal rectangles could also mean the tab switch
// never reached the layout. Under the unreserved cascade the Chat scroller // never reached the layout. Under the uncompensated cascade the overlay seat
// keeps its bar and the Trajectory branch becomes a hidden box with none, // loses its `right` compensation and measures the full padding box, so the
// and the card moves by half the band on each edge. // card moves by half the band on each edge. Chat's own reservation is
const comparison = await compareTabsWithoutReservation(page) // untouched — that is the side that must not change.
expect(comparison.chat.gutter).toBe('auto') const comparison = await compareTabsWithoutCompensation(page)
expect(comparison.chat.gutter).toBe('stable')
expect(comparison.chat.band).toBeGreaterThan(0) expect(comparison.chat.band).toBeGreaterThan(0)
expect(comparison.trajectory.band).toBe(0) expect(comparison.trajectory.band).toBe(0)
expect(comparison.leftShift).toBe(comparison.chat.band / 2) expect(comparison.leftShift).toBe(comparison.chat.band / 2)
expect(comparison.rightShift).toBe(comparison.chat.band / 2) expect(comparison.rightShift).toBe(comparison.chat.band / 2)
// Restoring the sheet restores the reservation, so the control cannot leak // Restoring the sheet restores the compensation, so the control cannot leak
// into the remaining measurements. // into the remaining measurements.
const restored = await compareTabs(page) const restored = await compareTabs(page)
expect(restored.leftShift).toBe(0) expect(restored.leftShift).toBe(0)
@@ -405,7 +415,7 @@ describe('web e2e: input card position across view tabs', () => {
await setMeasuredViewport(page, NARROW_VIEWPORT, true) await setMeasuredViewport(page, NARROW_VIEWPORT, true)
const narrow = await compareTabs(page) const narrow = await compareTabs(page)
await setMeasuredViewport(page, WIDE_VIEWPORT, false) await setMeasuredViewport(page, WIDE_VIEWPORT, false)
const control = await compareTabsWithoutReservation(page) const control = await compareTabsWithoutCompensation(page)
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(wide, narrow, control), MODE) await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(wide, narrow, control), MODE)
expect(tripwire.pageErrors).toEqual([]) expect(tripwire.pageErrors).toEqual([])
}, 60_000) }, 60_000)
@@ -5,9 +5,9 @@
- Chat: scrollbar-gutter stable, overflow hidden/auto - Chat: scrollbar-gutter stable, overflow hidden/auto
- Chat scroller scrolls: true - Chat scroller scrolls: true
- Chat reserved band: 8px - Chat reserved band: 8px
- Trajectory: scrollbar-gutter stable, overflow hidden/auto - Trajectory: scrollbar-gutter auto, overflow hidden/auto
- Trajectory scroller scrolls: false - Trajectory scroller scrolls: false
- Trajectory reserved band: 8px - Trajectory reserved band: 0px
- input card left edge moves between tabs: 0px - input card left edge moves between tabs: 0px
- input card right edge moves between tabs: 0px - input card right edge moves between tabs: 0px
- input card width changes between tabs: 0px - input card width changes between tabs: 0px
@@ -17,19 +17,19 @@
- Chat: scrollbar-gutter stable, overflow hidden/auto - Chat: scrollbar-gutter stable, overflow hidden/auto
- Chat scroller scrolls: true - Chat scroller scrolls: true
- Chat reserved band: 8px - Chat reserved band: 8px
- Trajectory: scrollbar-gutter stable, overflow hidden/auto - Trajectory: scrollbar-gutter auto, overflow hidden/auto
- Trajectory scroller scrolls: false - Trajectory scroller scrolls: false
- Trajectory reserved band: 8px - Trajectory reserved band: 0px
- input card left edge moves between tabs: 0px - input card left edge moves between tabs: 0px
- input card right edge moves between tabs: 0px - input card right edge moves between tabs: 0px
- input card width changes between tabs: 0px - input card width changes between tabs: 0px
## Wide viewport, reservation removed in the page (control) ## Wide viewport, seat compensation removed in the page (control)
- Chat: scrollbar-gutter auto, overflow hidden/auto - Chat: scrollbar-gutter stable, overflow hidden/auto
- Chat scroller scrolls: true - Chat scroller scrolls: true
- Chat reserved band: 8px - Chat reserved band: 8px
- Trajectory: scrollbar-gutter auto, overflow hidden/hidden - Trajectory: scrollbar-gutter auto, overflow hidden/auto
- Trajectory scroller scrolls: false - Trajectory scroller scrolls: false
- Trajectory reserved band: 0px - Trajectory reserved band: 0px
- input card left edge moves between tabs: 4px - input card left edge moves between tabs: 4px
@@ -0,0 +1,4 @@
- row "Load earlier history":
- cell "Load earlier history":
- button "Load earlier history":
- status
@@ -3,6 +3,7 @@
// DOM mounting stays bounded, and every scroll range remains reachable. // DOM mounting stays bounded, and every scroll range remains reachable.
import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path' import { join } from 'node:path'
import type { Browser, Page } from 'playwright' import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright' import { chromium } from 'playwright'
@@ -11,6 +12,8 @@ import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import type { ReplayEntry } from '@deepseek-ai/dsh-llm-replay' import type { ReplayEntry } from '@deepseek-ai/dsh-llm-replay'
import { createChatScrollFixture } from './chat-scroll-fixture.ts' import { createChatScrollFixture } from './chat-scroll-fixture.ts'
import { import {
captureStableAria,
compareOrRefreshGolden,
launchWebScaffold, launchWebScaffold,
seedSession, seedSession,
watchConsole, watchConsole,
@@ -20,6 +23,10 @@ import {
import { newEnglishPage, saveFailureShot } from './support.ts' import { newEnglishPage, saveFailureShot } from './support.ts'
const MODE = webSnapshotMode() const MODE = webSnapshotMode()
const LOAD_MORE_EXPECTED = fileURLToPath(new URL(
'./snapshots/trajectory-virtualization/load-more.expected.md',
import.meta.url,
))
const SESSION_ID = 'trajectory-virtualization-e2e' const SESSION_ID = 'trajectory-virtualization-e2e'
const FIXTURE = createChatScrollFixture({ const FIXTURE = createChatScrollFixture({
markerPrefix: 'TRAJECTORY_VIRTUAL', markerPrefix: 'TRAJECTORY_VIRTUAL',
@@ -150,10 +157,16 @@ async function loadToFirstTurn(page: Page): Promise<void> {
await scrollToRatio(page, 0) await scrollToRatio(page, 0)
if (await page.getByText(marker, { exact: false }).count() > 0) return if (await page.getByText(marker, { exact: false }).count() > 0) return
const before = await logicalRows(page) const before = await logicalRows(page)
const anchor = await firstVisibleRow(page)
await expect.poll(async () => ({ await expect.poll(async () => ({
marker: await page.getByText(marker, { exact: false }).count() > 0, marker: await page.getByText(marker, { exact: false }).count() > 0,
rows: await logicalRows(page), rows: await logicalRows(page),
}), { timeout: 30_000 }).not.toEqual({ marker: false, rows: before }) }), { timeout: 30_000 }).not.toEqual({ marker: false, rows: before })
await nextPaint(page)
await expect.poll(async () => {
const top = await rowTop(page, anchor.key)
return top === null ? Number.POSITIVE_INFINITY : Math.abs(top - anchor.top)
}, { timeout: 15_000 }).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
} }
throw new Error('trajectory did not reach the first turn after twelve older-page requests') throw new Error('trajectory did not reach the first turn after twelve older-page requests')
} }
@@ -230,8 +243,27 @@ describe('web e2e: Trajectory virtualization over tail-paged history', () => {
expect(await page.getByText('Initial System Prompt', { exact: true }).count()).toBe(0) expect(await page.getByText('Initial System Prompt', { exact: true }).count()).toBe(0)
expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS) expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
await scrollToRatio(page, 0) const loadMore = page.locator('[data-history-load] button')
await loadMore.waitFor({ timeout: 15_000 })
expect(await loadMore.textContent()).toBe('Load earlier history')
const loadMoreSnapshot = await captureStableAria(
page,
'[data-history-load]',
scaffold.workspaceCwd,
)
await compareOrRefreshGolden(LOAD_MORE_EXPECTED, loadMoreSnapshot, MODE)
// Avoid Playwright scrolling the offscreen first row into the automatic-load threshold.
await loadMore.evaluate((button: HTMLButtonElement) => { button.click() })
await expect.poll(() => held, { timeout: 15_000 }).toBe(true) await expect.poll(() => held, { timeout: 15_000 }).toBe(true)
await expect.poll(async () => ({
disabled: await loadMore.isDisabled(),
label: await loadMore.getAttribute('aria-label'),
}), { timeout: 15_000 }).toEqual({
disabled: true,
label: 'Loading earlier history…',
})
await scrollToRatio(page, 0)
const anchor = await firstVisibleRow(page) const anchor = await firstVisibleRow(page)
const selectedRow = page.locator( const selectedRow = page.locator(
`[data-trajectory-scroll] tr[data-trajectory-row-key=${JSON.stringify(anchor.key)}]`, `[data-trajectory-scroll] tr[data-trajectory-row-key=${JSON.stringify(anchor.key)}]`,
@@ -272,12 +272,17 @@
.scrollBody:has([data-conversation-composer-overlay]) { .scrollBody:has([data-conversation-composer-overlay]) {
position: relative; position: relative;
/* A clipping box nothing scrolls out of, stated as a scroll container on both /* A clipping box nothing scrolls out of, stated as a scroll container on both
axes rather than `overflow: hidden`: WebKit honours the reservation above axes rather than `overflow: hidden`: a single-axis scroller computes the
only in the `overflow-y: auto` form, and a single-axis scroller computes other axis to `auto`, and the seat's absolute positioning below needs the
the other axis to `auto` box to stay a containing block in every engine. */
([decision](../../../../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). */
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
/* The view owns its own scrollers (the trajectory ledger scrolls inside the
view), so this box never scrolls here; reserving a gutter would only
narrow the view's content by the bar's width. Chat keeps the reservation
on the same element, so the seat below compensates with the bar's width
([decision](../../../../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). */
scrollbar-gutter: auto;
} }
.scrollBody:has([data-conversation-composer-overlay]) > :global([data-slot='conversation.session']) > .viewArea { .scrollBody:has([data-conversation-composer-overlay]) > :global([data-slot='conversation.session']) > .viewArea {
@@ -288,7 +293,15 @@
.scrollBody:has([data-conversation-composer-overlay]) > .composerSeat { .scrollBody:has([data-conversation-composer-overlay]) > .composerSeat {
position: absolute; position: absolute;
right: 0; /* Chat's seat rides this box's content box, which its own scrolling shortens
by the bar's width; the overlay seat is against the padding box, so it
must give back the same width to keep the input card in place across
tabs. Reads ui-theme's --dsh-scrollbar-width, which mirrors the WebKit
bar's layout width. Chat reserves the engine-resolved width, so on an
engine where the resolved width differs from this fixed 8px the card
drifts by half the difference — accepted residual risk, recorded in
([decision](../../../../../../.agents/notes/implemented/bug-fix/2026-08-12-composer-overlay-seat-width-compensation.md)). */
right: var(--dsh-scrollbar-width);
bottom: 0; bottom: 0;
left: 0; left: 0;
} }
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-sidebar/README.md # pnpm run verify-translation-pairing --write packages/client/ui-sidebar/README.md
README.md: 9974118f69901de985e012e1b62f95a0bcee64c2 README.md: 7cc4fe0a722fe5f8cf0e983a0e3fdb30cd31bb64
README.zh.md: 11b0aa142cf62626ab6105e2c405d506e35349b0 README.zh.md: 96b6f60f871758c126e6c387c6ce3f0993039af0
+2
View File
@@ -8,6 +8,8 @@ New Session starts the runtime's page-local frontend Session Intent. The runtime
`SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared `sidebar.workspaces` and `sidebar.settings` child slots, and injected `startSession` plus sidebar-toggle callbacks. There is no plugin store. `SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared `sidebar.workspaces` and `sidebar.settings` child slots, and injected `startSession` plus sidebar-toggle callbacks. There is no plugin store.
During a live collapse, the shell holds the expanded content at its current width while it fades out for 150ms. The four upper controls—the shell toggle and New Session plus add and search rendered through `sidebar.workspaces`—then share one 150ms fade and 49px leftward translation into the 56px rail, ending with the layout's 300ms column slide; every 36px control box follows the same path to the rail's 10px left inset. The bottom-pinned `sidebar.settings` control shares the fade timing but has no horizontal translation. A page that starts collapsed renders the rail statically, and reduced-motion mode disables both transitions.
Scrollbars in the column are a pointer affordance: the shell rebinds ui-theme's [scrollbar indirection](../ui-theme/README.md) to `transparent` whenever the pointer is outside it, and keeps the thumb drawn for 2s after the pointer leaves, so a list nobody is pointing at carries no bar. The reservation that keeps rows from moving belongs to the scrolling region ([ui-workspace](../ui-workspace/README.md)), so revealing a thumb never reflows. Scrollbars in the column are a pointer affordance: the shell rebinds ui-theme's [scrollbar indirection](../ui-theme/README.md) to `transparent` whenever the pointer is outside it, and keeps the thumb drawn for 2s after the pointer leaves, so a list nobody is pointing at carries no bar. The reservation that keeps rows from moving belongs to the scrolling region ([ui-workspace](../ui-workspace/README.md)), so revealing a thumb never reflows.
The foot is the `sidebar.settings` seat: the sidebar renders only the bottom-pinned layout slot and shares its column state (`wide`); ui-settings registers the trigger row and settings panel there. The foot is the `sidebar.settings` seat: the sidebar renders only the bottom-pinned layout slot and shares its column state (`wide`); ui-settings registers the trigger row and settings panel there.
+2
View File
@@ -8,6 +8,8 @@ New Session 会启动运行时的页面局部前端 Session Intent。运行时
`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions``useWorkspaces` 钩子、已声明的 `sidebar.workspaces``sidebar.settings` 子 slot,以及注入的 `startSession` 与侧边栏切换回调。这里没有插件 store。 `SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions``useWorkspaces` 钩子、已声明的 `sidebar.workspaces``sidebar.settings` 子 slot,以及注入的 `startSession` 与侧边栏切换回调。这里没有插件 store。
实时收起时,外壳会把展开内容固定在当前宽度,并用 150ms 将其淡出。随后,上方四个控件——外壳的侧栏切换与新建会话,以及通过 `sidebar.workspaces` 渲染的添加和搜索——共用一次 150ms 的淡入和 49px 左移,在布局的 300ms 栏滑动结束时一起进入 56px 轨道;每个 36px 控件盒都会沿同一条路径到达轨道左侧 10px 的内边距。固定在底部的 `sidebar.settings` 控件只共用淡入时序,不发生横向位移。页面初始即为收起状态时会静态渲染轨道;减少动态效果模式会禁用两段过渡。
栏内的滚动条是一种指针可供性:只要指针不在栏内,外壳就把 ui-theme 的[滚动条间接层](../ui-theme/README.md)重新绑定为 `transparent`;指针离开后滑块再保留 2 秒,因此没人指向的列表不会带着滚动条。避免行位移的空间预留属于滚动区域本身([ui-workspace](../ui-workspace/README.md)),所以显示滑块不会引起重排。 栏内的滚动条是一种指针可供性:只要指针不在栏内,外壳就把 ui-theme 的[滚动条间接层](../ui-theme/README.md)重新绑定为 `transparent`;指针离开后滑块再保留 2 秒,因此没人指向的列表不会带着滚动条。避免行位移的空间预留属于滚动区域本身([ui-workspace](../ui-workspace/README.md)),所以显示滑块不会引起重排。
页脚承载 `sidebar.settings`:侧边栏只渲染固定在底部的布局 slot,并共享其栏状态(`wide`);ui-settings 在此注册触发行和设置面板。 页脚承载 `sidebar.settings`:侧边栏只渲染固定在底部的布局 slot,并共享其栏状态(`wide`);ui-settings 在此注册触发行和设置面板。
@@ -58,24 +58,35 @@
from { opacity: 0; } from { opacity: 0; }
} }
/* Rail controls hold hidden while the column slides shut, then fade in over /* At the 150ms rail settle, the four upper controls enter from the former
the slide's tail: .railIn applies at settle (150ms into the 0.3s AppFrame rail right edge over the remaining 150ms of the AppFrame track transition.
track transition), so a 100ms delay + 150ms fade starts just before the The bottom-pinned settings seat shares their opacity timeline but stays
slide ends (250ms) and finishes at 400ms; `backwards` keeps them at horizontally fixed. Only a live collapse gets .railIn; a cold collapsed
opacity 0 through the delay. Only a live collapse gets .railIn — a render stays static. */
refresh straight into the collapsed state renders statically. */
.railIn .iconButton, .railIn .iconButton,
.railIn .newSession, .railIn .newSession,
.railIn .regionArea {
animation: rail-in 150ms var(--ds-ease-in-out) backwards;
}
.railIn .footArea { .railIn .footArea {
animation: rail-in 150ms var(--ds-ease-in-out) 100ms backwards; animation: rail-fade-in 150ms var(--ds-ease-in-out) backwards;
} }
@keyframes rail-in { @keyframes rail-in {
from {
opacity: 0;
transform: translateX(49px);
}
}
@keyframes rail-fade-in {
from { opacity: 0; } from { opacity: 0; }
} }
/* Logo row (figma pad (4,8,4,8)): brand left, panel toggle right-anchored — /* Logo row (figma pad (4,8,4,8)): expanded keeps the panel toggle at the
the toggle is the rail's expand control and slides in with the right edge. */ right edge; the rail gives every control the same base left anchor before
the shared entry translation is applied. */
.logoRow { .logoRow {
flex: none; flex: none;
display: flex; display: flex;
@@ -93,6 +104,7 @@
height: 36px; height: 36px;
padding: 0; padding: 0;
margin-bottom: 12px; margin-bottom: 12px;
justify-content: flex-start;
} }
/* Brand group (figma I133:7632): the full wordmark rides the text ink /* Brand group (figma I133:7632): the full wordmark rides the text ink
@@ -185,6 +197,8 @@
} }
.collapsed .newSession { .collapsed .newSession {
align-self: flex-start;
width: 36px;
height: 36px; height: 36px;
padding: 0; padding: 0;
margin: 0 0 12px; margin: 0 0 12px;
@@ -262,7 +276,8 @@
.fading > *, .fading > *,
.railIn .iconButton, .railIn .iconButton,
.railIn .newSession, .railIn .newSession,
.railIn .footArea { .railIn .footArea,
.railIn .regionArea {
transition: none; transition: none;
animation: none; animation: none;
} }
@@ -2,12 +2,13 @@
* Sidebar shell: column geometry only. Collapse is a slide plus crossfade: * Sidebar shell: column geometry only. Collapse is a slide plus crossfade:
* content freezes at its expanded width (inline style) and fades out in place * content freezes at its expanded width (inline style) and fades out in place
* while the sliding column (AppFrame grid tracks) clips it — nothing reflows * while the sliding column (AppFrame grid tracks) clips it — nothing reflows
* mid-slide. At settle the wide-only content unmounts and the control rows * mid-slide. At settle the wide-only content unmounts and the four upper
* snap to the 56px rail (one icon each, same top-down order) fading in as the * controls enter the 56px rail from the same horizontal offset (one icon each,
* slide ends. The workspace/session browsing region between the New Session * same top-down order) on one fade that ends with the slide. The bottom-pinned
* button and the foot is the `sidebar.workspaces` registrant's, and the foot * settings control only fades. The workspace/session browsing region between
* holds `sidebar.settings` plus `sidebar.footer.action`; the shell hands them the wide flag * the New Session button and the foot is the `sidebar.workspaces` registrant's,
* (plus an expand request callback for the browser). * and the foot holds `sidebar.settings` plus `sidebar.footer.action`; the shell
* hands them the wide flag (plus an expand request callback for the browser).
* *
* The column also owns whether the scroll regions nested in it draw a * The column also owns whether the scroll regions nested in it draw a
* scrollbar at all: the shell tracks the pointer and rebinds ui-theme's * scrollbar at all: the shell tracks the pointer and rebinds ui-theme's
@@ -1,4 +1,4 @@
/** Sidebar shell inset contract shared with the nested workspace browser. */ /** Sidebar shell style contracts shared with its slot-owned controls. */
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
@@ -25,7 +25,7 @@ function declarations(selector: string): Map<string, string> | undefined {
return undefined return undefined
} }
describe('SidebarRoot.module.css inset', () => { describe('SidebarRoot.module.css', () => {
it('shares and cancels the wide shell trailing padding structurally', () => { it('shares and cancels the wide shell trailing padding structurally', () => {
const root = declarations('.root') const root = declarations('.root')
expect(root?.get('--dsh-sidebar-inline-padding')).toBe('12px') expect(root?.get('--dsh-sidebar-inline-padding')).toBe('12px')
@@ -39,4 +39,28 @@ describe('SidebarRoot.module.css inset', () => {
expect(declarations('.collapsed .regionArea')?.get('padding-left')).toBe('0') expect(declarations('.collapsed .regionArea')?.get('padding-left')).toBe('0')
expect(declarations('.collapsed .regionArea')?.get('margin-right')).toBe('0') expect(declarations('.collapsed .regionArea')?.get('margin-right')).toBe('0')
}) })
it('moves the four upper controls while the settings seat only fades', () => {
const animation = 'rail-in 150ms var(--ds-ease-in-out) backwards'
for (const selector of [
'.railIn .iconButton',
'.railIn .newSession',
'.railIn .regionArea',
]) {
expect(declarations(selector)?.get('animation')).toBe(animation)
}
expect(declarations('.railIn .footArea')?.get('animation')).toBe(
'rail-fade-in 150ms var(--ds-ease-in-out) backwards',
)
expect(css).toMatch(
/@keyframes rail-in\s*\{\s*from\s*\{\s*opacity: 0;\s*transform: translateX\(49px\);\s*}\s*}/,
)
expect(css).toMatch(/@keyframes rail-fade-in\s*\{\s*from\s*\{\s*opacity: 0;\s*}\s*}/)
})
it('gives shell rail controls the same base anchor for their shared translation', () => {
expect(declarations('.collapsed .logoRow')?.get('justify-content')).toBe('flex-start')
expect(declarations('.collapsed .newSession')?.get('align-self')).toBe('flex-start')
expect(declarations('.collapsed .newSession')?.get('width')).toBe('36px')
})
}) })
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-theme/README.md # pnpm run verify-translation-pairing --write packages/client/ui-theme/README.md
README.md: 9388d5c05e5625d1a38b27c4565edf3ed47b3b6d README.md: e84a21f085eaef7546321edc90847a9a3f9bb2b8
README.zh.md: 9995980e4a64b07c376f90e223c1ec83b5881b73 README.zh.md: 758b6f52d330f4518a2df5267117b61446102ea7
+1 -1
View File
@@ -8,7 +8,7 @@ When the host composition includes an HTTP server, the host half injects a synch
`src/styles/` holds five sheets, all imported by the web shell's `base.css`: `base.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. `scrollbar.css` is the sole consumer of the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. `src/styles/` holds five sheets, all imported by the web shell's `base.css`: `base.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. `scrollbar.css` is the sole consumer of the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them.
Scrollbar rebinding contract: `scrollbar.css` binds `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` on `body` to the l1 (base-surface) tokens, and both rendering paths read that pair. An elevated surface (menu, popover, dialog) 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; one rebind retints whichever path the engine took. The pair's other legal target is `transparent`, which draws no thumb at all — [ui-sidebar](../ui-sidebar/README.md) rebinds its column that way while the pointer is elsewhere. A rebind to the l1 pair is not a rebind; it restates the base-surface default. Scrollbar rebinding contract: `scrollbar.css` binds `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` on `body` to the l1 (base-surface) tokens, and both rendering paths read that pair. An elevated surface (menu, popover, dialog) 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; one rebind retints whichever path the engine took. The pair's other legal target is `transparent`, which draws no thumb at all — [ui-sidebar](../ui-sidebar/README.md) rebinds its column that way while the pointer is elsewhere. A rebind to the l1 pair is not a rebind; it restates the base-surface default. `--dsh-scrollbar-width` mirrors the WebKit bar's layout width for surfaces that align themselves beside a space-consuming bar — [ui-conversation](../ui-conversation/README.md) reads it for the overlay composer seat's `right` offset — and the scrollbar-styles spec pairs it with the mirrored rule and the consumer.
The two paths are mutually exclusive by construction. `scrollbar-width`/`scrollbar-color` sit inside `@supports not selector(::-webkit-scrollbar)` because a non-`auto` value of either makes Chromium and Safari discard every `::-webkit-scrollbar*` rule for that element, `::-webkit-scrollbar-thumb:hover` included — declaring both unconditionally leaves `--dsh-scrollbar-thumb-hover` with no rendering anywhere. Firefox therefore takes the standard properties and WebKit-based engines take the pseudo-elements, so the hover token only ever renders through the pseudo-element path. Reasoning and the measured computed values: [the scrollbar Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md). The two paths are mutually exclusive by construction. `scrollbar-width`/`scrollbar-color` sit inside `@supports not selector(::-webkit-scrollbar)` because a non-`auto` value of either makes Chromium and Safari discard every `::-webkit-scrollbar*` rule for that element, `::-webkit-scrollbar-thumb:hover` included — declaring both unconditionally leaves `--dsh-scrollbar-thumb-hover` with no rendering anywhere. Firefox therefore takes the standard properties and WebKit-based engines take the pseudo-elements, so the hover token only ever renders through the pseudo-element path. Reasoning and the measured computed values: [the scrollbar Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md).
+1 -1
View File
@@ -8,7 +8,7 @@
`src/styles/` 下有五张样式表,全部由 web 壳的 `base.css` 导入:`base.css``design-platform.css``scrollbar.css``gradient-shadow-text.css``shiki.css``scrollbar.css``--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。 `src/styles/` 下有五张样式表,全部由 web 壳的 `base.css` 导入:`base.css``design-platform.css``scrollbar.css``gradient-shadow-text.css``shiki.css``scrollbar.css``--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。
滚动条重新绑定约定:`scrollbar.css``body` 上把 `--dsh-scrollbar-thumb``--dsh-scrollbar-thumb-hover` 绑定到 l1(基础表面)token,两条渲染路径都读取这一组变量。高层级表面(菜单、浮层、对话框)在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)``--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`;一次重新绑定即可为引擎实际走的那条路径换色。这组变量的另一个合法目标是 `transparent`,即完全不绘制滑块——[ui-sidebar](../ui-sidebar/README.md) 在指针不在栏内时就这样重新绑定自己的列。绑回 l1 那组不算重新绑定,它只是重述基础表面的默认值。 滚动条重新绑定约定:`scrollbar.css``body` 上把 `--dsh-scrollbar-thumb``--dsh-scrollbar-thumb-hover` 绑定到 l1(基础表面)token,两条渲染路径都读取这一组变量。高层级表面(菜单、浮层、对话框)在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)``--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`;一次重新绑定即可为引擎实际走的那条路径换色。这组变量的另一个合法目标是 `transparent`,即完全不绘制滑块——[ui-sidebar](../ui-sidebar/README.md) 在指针不在栏内时就这样重新绑定自己的列。绑回 l1 那组不算重新绑定,它只是重述基础表面的默认值。`--dsh-scrollbar-width` 镜像 WebKit 滚动条的布局宽度,供需要与占布局宽度的滚动条对齐的表面使用——[ui-conversation](../ui-conversation/README.md) 用它作为覆盖 composer 座位 `right` 偏移——scrollbar-styles 规格把它与镜像规则及消费者配对检查。
两条路径在构造上互斥。`scrollbar-width``scrollbar-color` 写在 `@supports not selector(::-webkit-scrollbar)` 之内,因为这两个属性中的任一个只要取非 `auto` 值,Chromium 与 Safari 就会丢弃该元素上的全部 `::-webkit-scrollbar*` 规则,`::-webkit-scrollbar-thumb:hover` 也在其中——若无条件地同时声明,`--dsh-scrollbar-thumb-hover` 在任何引擎上都不会被渲染。因此 Firefox 走标准属性,WebKit 系引擎走伪元素,hover token 只经由伪元素这条路径渲染。相关原理与实测计算值见[滚动条 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)。 两条路径在构造上互斥。`scrollbar-width``scrollbar-color` 写在 `@supports not selector(::-webkit-scrollbar)` 之内,因为这两个属性中的任一个只要取非 `auto` 值,Chromium 与 Safari 就会丢弃该元素上的全部 `::-webkit-scrollbar*` 规则,`::-webkit-scrollbar-thumb:hover` 也在其中——若无条件地同时声明,`--dsh-scrollbar-thumb-hover` 在任何引擎上都不会被渲染。因此 Firefox 走标准属性,WebKit 系引擎走伪元素,hover token 只经由伪元素这条路径渲染。相关原理与实测计算值见[滚动条 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)。
@@ -17,6 +17,11 @@
body { body {
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l1); --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l1);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l1); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l1);
/* The WebKit bar's layout width, mirrored by the ::-webkit-scrollbar rule
below. A surface that must align itself beside a space-consuming bar
(ConversationRoot's overlay composer seat) reads this instead of
hardcoding the number. */
--dsh-scrollbar-width: 8px;
} }
/* The two paths are mutually exclusive, and the gate is load-bearing rather /* The two paths are mutually exclusive, and the gate is load-bearing rather
@@ -2,10 +2,11 @@
* Scrollbar stylesheet contract, asserted against the CSS text on disk: every * Scrollbar stylesheet contract, asserted against the CSS text on disk: every
* --dsw-alias-scrollbar-* token design-platform.css defines has a consumer, * --dsw-alias-scrollbar-* token design-platform.css defines has a consumer,
* scrollbar.css binds the base-surface pair through the rebindable * scrollbar.css binds the base-surface pair through the rebindable
* indirection, and elevated surfaces rebind that indirection in complete * indirection, the width variable mirrors the ::-webkit-scrollbar rule for
* pairs. The expected token set is scanned out of design-platform.css, so * consumers that align beside the bar, and elevated surfaces rebind that
* adding, renaming, or dropping a scrollbar token moves these assertions with * indirection in complete pairs. The expected token set is scanned out of
* it. * design-platform.css, so adding, renaming, or dropping a scrollbar token
* moves these assertions with it.
*/ */
import { readdirSync, readFileSync } from 'node:fs' import { readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
@@ -341,6 +342,67 @@ describe('scrollbar.css base-surface binding', () => {
}) })
}) })
describe('scrollbar.css width variable', () => {
const WIDTH_VARIABLE = `${INDIRECTION_PREFIX}width`
it('defines the width variable on body as a static length', () => {
// The overlay seat compensation reads a fixed number, not a second
// indirection: the mirror check below compares the WebKit rule against
// this value, so a var()-to-var() chain would compare one indirection to
// another instead of pinning the number.
const value = scrollbarRules
.filter(rule => rule.selectors.includes('body'))
.flatMap(rule => rule.declarations)
.findLast(([property]) => property === WIDTH_VARIABLE)?.[1]
expect(value, WIDTH_VARIABLE).toBeDefined()
expect(value, WIDTH_VARIABLE).toMatch(/^\d+(?:\.\d+)?px$/)
})
it('mirrors the ::-webkit-scrollbar width rule with the variable value', () => {
// The compensation stays aligned with the WebKit bar only while both read
// the same number. A change to one side without the other puts the overlay
// seat a band off from Chat on WebKit engines.
const variableValue = scrollbarRules
.filter(rule => rule.selectors.includes('body'))
.flatMap(rule => rule.declarations)
.findLast(([property]) => property === WIDTH_VARIABLE)?.[1]
const webkitWidth = scrollbarRules
.filter(rule => rule.selectors.includes('::-webkit-scrollbar'))
.flatMap(rule => rule.declarations)
.findLast(([property]) => property === 'width')?.[1]
expect(webkitWidth, '::-webkit-scrollbar width').toBeDefined()
expect(webkitWidth).toBe(variableValue)
})
it('every reader of the width variable outside ui-theme references a defined variable', () => {
// The consumer is ConversationRoot's overlay composer seat
// (`right: var(--dsh-scrollbar-width)`); a rename in scrollbar.css without
// the consumer, or a typo in the consumer, leaves the value
// guaranteed-invalid and the seat loses the band. The equal-rectangle e2e
// would catch it only on an engine that draws the bar, so the sheet
// contract states it here.
const defined = new Set(
scrollbarRules
.flatMap(rule => rule.declarations)
.filter(([property]) => property.startsWith(INDIRECTION_PREFIX))
.map(([property]) => property),
)
expect(defined).toContain(WIDTH_VARIABLE)
const readers: string[] = []
for (const file of packageStylesheets()) {
if (file === fileURLToPath(new URL('scrollbar.css', STYLES))) continue
for (const rule of parseRules(readFileSync(file, 'utf8'))) {
for (const [property, value] of rule.declarations) {
for (const name of varReferences(value)) {
if (name === WIDTH_VARIABLE) readers.push(`${file} ${rule.selectors.join(', ')}: ${property}`)
}
}
}
}
expect(readers.length, 'compensation consumer').toBeGreaterThan(0)
})
})
describe('scrollbar.css selectors', () => { describe('scrollbar.css selectors', () => {
const scrollbarColorSelectors = scrollbarRules const scrollbarColorSelectors = scrollbarRules
.filter(rule => rule.declarations.some(([property]) => property === 'scrollbar-color')) .filter(rule => rule.declarations.some(([property]) => property === 'scrollbar-color'))
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md # pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
README.md: d3786b6460c5df7eaa6d24e68c80025e7fb29ae4 README.md: 9ad42a3d3bcfb8fbf4842a66577e65794a716dfd
README.zh.md: b3bc88d8ab73ccc0da9a49d1867fe2c66be5ca43 README.zh.md: df7661ef7d4861dbc6c8e365a18b3e3252d4d4af
+1 -1
View File
@@ -2,7 +2,7 @@
English | [中文](README.zh.md) English | [中文](README.zh.md)
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8. Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned. While an older prefix remains unloaded, a first-row control precedes the loaded records, loads one earlier page on click, and changes in place to a disabled loading status while that page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
## Model Experience ## Model Experience
+1 -1
View File
@@ -2,7 +2,7 @@
[English](README.md) | 中文 [English](README.md) | 中文
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面的加载尚未完成时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。 Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前,记录表会用明确的加载行遮住真实记录。更早的前缀仍未加载时,已加载记录前会始终保留首行控件;单击它会加载一页更早的历史,页面加载期间则会原地变为禁用的加载状态。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。
## 模型体验 ## 模型体验
@@ -55,6 +55,53 @@
animation: history-loading-spin 700ms linear infinite; animation: history-loading-spin 700ms linear infinite;
} }
.table tbody .historyLoadRow td {
height: 30px;
padding: 0;
}
/* The pagination control does not create a trajectory turn boundary. */
.table tbody .historyLoadRow + tr[data-turn-start='true'] td::before {
content: none;
}
.historyLoadButton {
display: flex;
width: 100%;
height: 29px;
align-items: center;
justify-content: center;
gap: 6px;
border: 0;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-secondary);
cursor: pointer;
font: var(--dsw-font-xxs-12);
}
.historyLoadButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.historyLoadButton:focus-visible {
outline: 2px solid var(--dsw-alias-state-business-primary);
outline-offset: -2px;
}
.historyLoadButton:disabled {
cursor: default;
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.table:not([data-scroll-ready='true']) { .table:not([data-scroll-ready='true']) {
visibility: hidden; visibility: hidden;
} }
@@ -137,7 +184,7 @@
border: 0; border: 0;
} }
.table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]) { .table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]):not([data-history-load]) {
cursor: default; cursor: default;
outline: none; outline: none;
transition: transition:
@@ -149,7 +196,7 @@
opacity: 0.24; opacity: 0.24;
} }
.table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]):not([data-selected='true']):hover { .table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]):not([data-history-load]):not([data-selected='true']):hover {
background: var(--dsw-alias-interactive-bg-hover); background: var(--dsw-alias-interactive-bg-hover);
} }
@@ -30,6 +30,7 @@ import css from './TrajectoryTable.module.css'
const BOTTOM_FOLLOW_THRESHOLD_PX = 2 const BOTTOM_FOLLOW_THRESHOLD_PX = 2
const OLDER_LOAD_THRESHOLD_PX = 48 const OLDER_LOAD_THRESHOLD_PX = 48
const HISTORY_LOAD_ROW_HEIGHT_PX = 30
const VIRTUALIZATION_THRESHOLD = 100 const VIRTUALIZATION_THRESHOLD = 100
const VIRTUAL_OVERSCAN_ROWS = 12 const VIRTUAL_OVERSCAN_ROWS = 12
const VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX = 600 const VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX = 600
@@ -364,6 +365,8 @@ export interface TrajectoryTableProps {
recordFocus?: { readonly index: number } | null recordFocus?: { readonly index: number } | null
/** Whether the initial history tail is still loading. */ /** Whether the initial history tail is still loading. */
historyLoading?: boolean historyLoading?: boolean
/** Whether one older history page request is pending anywhere. */
olderHistoryLoading?: boolean
/** First loaded raw event, used to preserve scroll position after prepending a page. */ /** First loaded raw event, used to preserve scroll position after prepending a page. */
historyStartSeq?: number | undefined historyStartSeq?: number | undefined
/** Whether one older history page can be requested. */ /** Whether one older history page can be requested. */
@@ -1698,6 +1701,7 @@ export function TrajectoryTable({
recordSelection = null, recordSelection = null,
recordFocus = null, recordFocus = null,
historyLoading = false, historyLoading = false,
olderHistoryLoading = false,
historyStartSeq, historyStartSeq,
hasOlderRecords = false, hasOlderRecords = false,
onLoadOlder, onLoadOlder,
@@ -1769,6 +1773,7 @@ export function TrajectoryTable({
const virtualRowStructure = useStableVirtualRowStructure(projectedVirtualRows) const virtualRowStructure = useStableVirtualRowStructure(projectedVirtualRows)
const virtualizationEnabled = hasOlderRecords const virtualizationEnabled = hasOlderRecords
|| records.length > VIRTUALIZATION_THRESHOLD || records.length > VIRTUALIZATION_THRESHOLD
const virtualScrollMargin = hasOlderRecords ? HISTORY_LOAD_ROW_HEIGHT_PX : 0
const estimateVirtualRowSize = useCallback( const estimateVirtualRowSize = useCallback(
(index: number) => virtualRowStructure[index]?.height ?? 30, (index: number) => virtualRowStructure[index]?.height ?? 30,
[virtualRowStructure], [virtualRowStructure],
@@ -1787,6 +1792,7 @@ export function TrajectoryTable({
initialRect: { width: 0, height: VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX }, initialRect: { width: 0, height: VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX },
anchorTo: 'end', anchorTo: 'end',
overscan: VIRTUAL_OVERSCAN_ROWS, overscan: VIRTUAL_OVERSCAN_ROWS,
scrollMargin: virtualScrollMargin,
scrollEndThreshold: BOTTOM_FOLLOW_THRESHOLD_PX, scrollEndThreshold: BOTTOM_FOLLOW_THRESHOLD_PX,
}) })
const virtualIndexByRecordId = useMemo(() => { const virtualIndexByRecordId = useMemo(() => {
@@ -1801,10 +1807,15 @@ export function TrajectoryTable({
return indexes return indexes
}, [projectedVirtualRows]) }, [projectedVirtualRows])
const virtualItems = virtualizationEnabled ? rowVirtualizer.getVirtualItems() : [] const virtualItems = virtualizationEnabled ? rowVirtualizer.getVirtualItems() : []
const virtualTop = virtualItems[0]?.start ?? 0 const virtualTop = Math.max(0, (virtualItems[0]?.start ?? 0) - virtualScrollMargin)
const virtualBottom = virtualItems.length === 0 const virtualBottom = virtualItems.length === 0
? 0 ? 0
: Math.max(0, rowVirtualizer.getTotalSize() - (virtualItems.at(-1)?.end ?? 0)) : Math.max(
0,
rowVirtualizer.getTotalSize()
+ virtualScrollMargin
- (virtualItems.at(-1)?.end ?? 0),
)
const renderedRecords = virtualizationEnabled const renderedRecords = virtualizationEnabled
? virtualItems.flatMap((item) => { ? virtualItems.flatMap((item) => {
const row = projectedVirtualRows[item.index] const row = projectedVirtualRows[item.index]
@@ -2123,12 +2134,13 @@ export function TrajectoryTable({
virtualIndexByRecordId, virtualIndexByRecordId,
virtualizationEnabled, virtualizationEnabled,
]) ])
const requestOlder = useCallback((pane: HTMLDivElement) => { const requestOlder = useCallback((pane: HTMLDivElement, requireTop: boolean) => {
if ( if (
!hasOlderRecords !hasOlderRecords
|| onLoadOlder === undefined || onLoadOlder === undefined
|| loadingOlder.current || loadingOlder.current
|| pane.scrollTop > OLDER_LOAD_THRESHOLD_PX || olderHistoryLoading
|| (requireTop && pane.scrollTop > OLDER_LOAD_THRESHOLD_PX)
) return ) return
loadingOlder.current = true loadingOlder.current = true
setOlderLoading(true) setOlderLoading(true)
@@ -2143,7 +2155,7 @@ export function TrajectoryTable({
loadingOlder.current = false loadingOlder.current = false
setOlderLoading(false) setOlderLoading(false)
}) })
}, [hasOlderRecords, historyStartSeq, onLoadOlder]) }, [hasOlderRecords, historyStartSeq, olderHistoryLoading, onLoadOlder])
useLayoutEffect(() => { useLayoutEffect(() => {
const pane = tablePaneRef.current const pane = tablePaneRef.current
if (pane === null) return if (pane === null) return
@@ -2176,10 +2188,9 @@ export function TrajectoryTable({
virtualizationEnabled, virtualizationEnabled,
]) ])
const loadingLabel = olderLoading const olderBusy = olderHistoryLoading || olderLoading
? 'Loading earlier history…' const showInitialLoading = historyLoading || !tableScrollReady
: 'Loading trajectory…' const historyRowOffset = hasOlderRecords ? 1 : 0
const showLoading = historyLoading || olderLoading || !tableScrollReady
return ( return (
<div ref={rootRef} className={css.split} style={splitStyle}> <div ref={rootRef} className={css.split} style={splitStyle}>
@@ -2192,30 +2203,62 @@ export function TrajectoryTable({
followsTableTail.current = followsTableTail.current =
pane.scrollHeight - pane.clientHeight - pane.scrollTop pane.scrollHeight - pane.clientHeight - pane.scrollTop
<= BOTTOM_FOLLOW_THRESHOLD_PX <= BOTTOM_FOLLOW_THRESHOLD_PX
requestOlder(pane) requestOlder(pane, true)
}} }}
onClick={(event) => { onClick={(event) => {
if (event.target === event.currentTarget) clearAllSelections() if (event.target === event.currentTarget) clearAllSelections()
}} }}
> >
{showLoading && ( {showInitialLoading && (
<div className={css.historyLoading} role="status" aria-live="polite"> <div className={css.historyLoading} role="status" aria-live="polite">
<span className={css.historyLoadingBar}> <span className={css.historyLoadingBar}>
<span className={css.historyLoadingSpinner} aria-hidden="true" /> <span className={css.historyLoadingSpinner} aria-hidden="true" />
{loadingLabel} Loading trajectory
</span> </span>
</div> </div>
)} )}
<table <table
className={css.table} className={css.table}
data-scroll-ready={tableScrollReady || undefined} data-scroll-ready={tableScrollReady || undefined}
aria-rowcount={records.length} aria-rowcount={records.length + historyRowOffset}
> >
<colgroup> <colgroup>
<col className={css.eventColumn} /> <col className={css.eventColumn} />
<col className={css.contentColumn} /> <col className={css.contentColumn} />
</colgroup> </colgroup>
<tbody> <tbody>
{hasOlderRecords && (
<tr
className={css.historyLoadRow}
data-history-load=""
aria-rowindex={1}
>
<td colSpan={2}>
<button
type="button"
className={css.historyLoadButton}
disabled={olderBusy || onLoadOlder === undefined}
aria-label={olderBusy
? 'Loading earlier history…'
: 'Load earlier history'}
onClick={() => {
const pane = tablePaneRef.current
if (pane !== null) requestOlder(pane, false)
}}
>
{olderBusy && (
<span className={css.historyLoadingSpinner} aria-hidden="true" />
)}
<span aria-hidden="true">
{olderBusy ? 'Loading earlier history…' : 'Load earlier history'}
</span>
<span className={css.visuallyHidden} role="status" aria-live="polite">
{olderBusy ? 'Loading earlier history…' : ''}
</span>
</button>
</td>
</tr>
)}
{virtualTop > 0 && ( {virtualTop > 0 && (
<tr className={css.virtualSpacer} data-virtual-spacer="top" aria-hidden="true"> <tr className={css.virtualSpacer} data-virtual-spacer="top" aria-hidden="true">
<td <td
@@ -2263,7 +2306,7 @@ export function TrajectoryTable({
return ( return (
<tr <tr
tabIndex={isRequestOnly ? -1 : 0} tabIndex={isRequestOnly ? -1 : 0}
aria-rowindex={position + 1} aria-rowindex={position + 1 + historyRowOffset}
aria-label={isCollapsedSummary aria-label={isCollapsedSummary
? `Collapsed ${record.collapsedSummaryKind} summary, ${record.collapsedSummary}` ? `Collapsed ${record.collapsedSummaryKind} summary, ${record.collapsedSummary}`
: isRequestOnly : isRequestOnly
@@ -141,8 +141,8 @@ export function TrajectoryView({
} | null>(null) } | null>(null)
const inspection = useSession(snapshot => const inspection = useSession(snapshot =>
snapshot.views.get('trajectory') ?? EMPTY_TRAJECTORY_SNAPSHOT) snapshot.views.get('trajectory') ?? EMPTY_TRAJECTORY_SNAPSHOT)
const historyLoading = useSession(snapshot => const historyLoading = useSession(snapshot => snapshot.openState === 'loading')
snapshot.openState === 'loading' || snapshot.loadingOlder) const olderHistoryLoading = useSession(snapshot => snapshot.loadingOlder)
const hasOlderHistory = useSession(snapshot => snapshot.hasMore) const hasOlderHistory = useSession(snapshot => snapshot.hasMore)
const nodes = inspection.eventNodes const nodes = inspection.eventNodes
const eventLocations = inspection.eventLocations const eventLocations = inspection.eventLocations
@@ -488,6 +488,7 @@ export function TrajectoryView({
recordSelection={timelineRecordSelection} recordSelection={timelineRecordSelection}
recordFocus={timelineRecordFocus} recordFocus={timelineRecordFocus}
historyLoading={historyLoading} historyLoading={historyLoading}
olderHistoryLoading={olderHistoryLoading}
historyStartSeq={historyBaseSeq} historyStartSeq={historyBaseSeq}
hasOlderRecords={hasOlderHistory} hasOlderRecords={hasOlderHistory}
onLoadOlder={loadEarlierHistory} onLoadOlder={loadEarlierHistory}
@@ -418,7 +418,9 @@ describe('TrajectoryTable', () => {
await waitFor(() => { expect(onLoadOlder).toHaveBeenCalledOnce() }) await waitFor(() => { expect(onLoadOlder).toHaveBeenCalledOnce() })
expect(screen.getByRole('status').textContent).toContain('Loading earlier history…') expect(screen.getByRole('status').textContent).toContain('Loading earlier history…')
resolveOlder?.(true) resolveOlder?.(true)
await waitFor(() => { expect(screen.queryByRole('status')).toBeNull() }) await waitFor(() => {
expect(screen.getByRole('status').textContent).toBe('')
})
scrollHeight = 260 scrollHeight = 260
view.rerender( view.rerender(
<TrajectoryTable <TrajectoryTable
@@ -438,6 +440,68 @@ describe('TrajectoryTable', () => {
expect(tablePane.scrollTop).toBe(60) expect(tablePane.scrollTop).toBe(60)
}) })
it('keeps an idle older-history control as the first row until paging completes', async () => {
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: vi.fn(),
})
let resolveOlder: ((advanced: boolean) => void) | undefined
const older = new Promise<boolean>((resolve) => { resolveOlder = resolve })
const onLoadOlder = vi.fn(() => older)
const view = render(
<TrajectoryTable
turns={TURNS}
{...FOLD_PROPS}
hasOlderRecords
onLoadOlder={onLoadOlder}
/>,
)
const table = screen.getByRole('table')
const loadButton = screen.getByRole('button', { name: 'Load earlier history' })
const loadRow = table.querySelector('tbody > tr:first-child')
expect(loadRow?.contains(loadButton)).toBe(true)
expect(loadRow?.getAttribute('aria-rowindex')).toBe('1')
expect(screen.getByRole('status').textContent).toBe('')
expect(table.getAttribute('aria-rowcount')).toBe('4')
expect((await screen.findByRole('row', { name: /ASSISTANT/ })).getAttribute('aria-rowindex'))
.toBe('2')
fireEvent.click(loadButton)
expect(onLoadOlder).toHaveBeenCalledOnce()
expect(loadButton.hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('status').textContent).toBe('Loading earlier history…')
resolveOlder?.(false)
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Load earlier history' })
.hasAttribute('disabled')).toBe(false)
})
view.rerender(
<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />,
)
expect(screen.queryByRole('button', { name: 'Load earlier history' })).toBeNull()
expect(table.getAttribute('aria-rowcount')).toBe('3')
})
it('reflects an older page started outside the ledger in the persistent control', () => {
render(
<TrajectoryTable
turns={TURNS}
{...FOLD_PROPS}
hasOlderRecords
olderHistoryLoading
onLoadOlder={vi.fn(async () => true)}
/>,
)
expect(screen.getByRole('button', { name: 'Loading earlier history…' })
.hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('status').textContent).toBe('Loading earlier history…')
})
it('covers the ledger while the initial tail is loading', () => { it('covers the ledger while the initial tail is loading', () => {
const view = render( const view = render(
<TrajectoryTable turns={TURNS} {...FOLD_PROPS} historyLoading />, <TrajectoryTable turns={TURNS} {...FOLD_PROPS} historyLoading />,
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md # pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
README.md: 9d7d4d77cc064146f1fdaed615509215c64308fc README.md: 8878aa49dcccd60ddcde5f0a9563bbfbd969c9c0
README.zh.md: ca35d7cd2e7ff176f4ea40d1e9a3a6d1a7457462 README.zh.md: c83f1c53d5471dd8d52b933163a573eb98a70c2e
+1 -1
View File
@@ -6,7 +6,7 @@ Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar
The browser renders grouped or flat Session rows from the global runtime hooks and owns Workspace add/rename/reorder plus Session reorder. A Workspace remembers whether it is closed or showing Sessions; an open Workspace shows five Sessions by default, offers a transient **Show more** control for the remainder, and returns to five after the whole Workspace is closed and reopened. Creating a Session from a Workspace row first opens that group so the new row remains visible when the Session state arrives. Once the Workspace list baseline is ready, browser-persisted expansion and Session-order records retain only current Workspace ids plus Ungrouped and the flat-list account. View options combine grouping with one browser-persisted Session order per account: real Workspaces initialize from `WorkspaceView.sessionIds`, while Ungrouped and the cross-Workspace flat list initialize from recency. **Manual** and **Last updated** apply in either presentation. Entering Last updated performs a complete recency sort and later user prompts or steers promote their Session once, while entering Manual preserves every current position and disables later promotion. Dragging edits the current order in either mode; Manual-mode drags for real Workspaces also update the Host Session account, while Ungrouped and flat-list orders remain browser-local because neither has one Workspace account. Flat rows omit the empty leading status slot because they have no parent hierarchy, but retain it when a Session status is visible. Workspace drag order is Host-durable in either Session order mode. The browser renders grouped or flat Session rows from the global runtime hooks and owns Workspace add/rename/reorder plus Session reorder. A Workspace remembers whether it is closed or showing Sessions; an open Workspace shows five Sessions by default, offers a transient **Show more** control for the remainder, and returns to five after the whole Workspace is closed and reopened. Creating a Session from a Workspace row first opens that group so the new row remains visible when the Session state arrives. Once the Workspace list baseline is ready, browser-persisted expansion and Session-order records retain only current Workspace ids plus Ungrouped and the flat-list account. View options combine grouping with one browser-persisted Session order per account: real Workspaces initialize from `WorkspaceView.sessionIds`, while Ungrouped and the cross-Workspace flat list initialize from recency. **Manual** and **Last updated** apply in either presentation. Entering Last updated performs a complete recency sort and later user prompts or steers promote their Session once, while entering Manual preserves every current position and disables later promotion. Dragging edits the current order in either mode; Manual-mode drags for real Workspaces also update the Host Session account, while Ungrouped and flat-list orders remain browser-local because neither has one Workspace account. Flat rows omit the empty leading status slot because they have no parent hierarchy, but retain it when a Session status is visible. Workspace drag order is Host-durable in either Session order mode.
Collapsed search is one header action beside the view and add actions. Activating it expands the field across the header; an outside click collapses only a query that is empty after trimming, while the clear control always resets and collapses it. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event. Collapsed search is one header action beside the view and add actions. In the rail, add and search render as 36px controls on the shell's shared horizontal entry path. Activating search expands the field across the header; an outside click collapses only a query that is empty after trimming, while the clear control always resets and collapses it. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event.
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Distinct canonical paths remain separate id-keyed Workspaces when their basenames and display titles match; the sidebar hover detail exposes the full path. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default, under which the sidebar header drops its add button rather than offering a dead one). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands. The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Distinct canonical paths remain separate id-keyed Workspaces when their basenames and display titles match; the sidebar hover detail exposes the full path. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default, under which the sidebar header drops its add button rather than offering a dead one). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands.
+1 -1
View File
@@ -6,7 +6,7 @@
该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名/重排序以及 Session 重排序。每个 Workspace 会记住自身是关闭还是显示 Session;打开后默认显示五条 Session,其余条目通过临时的**展开其余**控件显示,而关闭并重新打开整个 Workspace 后会恢复为五条。从 Workspace 行创建 Session 时会先打开该分组,使 Session 状态到达后新行保持可见。Workspace 列表基线就绪后,浏览器持久化的展开状态与 Session 顺序记录只保留当前 Workspace id、Ungrouped 和单列表记账。视图选项把分组方式和每个记账各自的一份浏览器持久化 Session 顺序放在一起:真实 Workspace 从 `WorkspaceView.sessionIds` 初始化,Ungrouped 和跨 Workspace 的单列表则从最近更新时间顺序初始化。**手动排序**和**最近更新**在两种呈现方式下都可用。进入最近更新时会执行一次完整的时间排序,后续 user prompt 或 steer 会将对应 Session 置顶一次;进入手动排序则保留所有当前位置并停用后续置顶。两种模式下的拖拽都会编辑当前顺序;真实 Workspace 在手动模式下的拖拽还会更新 Host Session 记账,而 Ungrouped 和单列表因没有单一 Workspace 记账,其顺序始终只保存在浏览器本地。单列表没有父级层次,因此不显示空的左侧状态槽;Session 存在可见状态时仍保留该槽。无论采用哪种 Session 顺序,Workspace 拖拽顺序都由 Host 持久化。 该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名/重排序以及 Session 重排序。每个 Workspace 会记住自身是关闭还是显示 Session;打开后默认显示五条 Session,其余条目通过临时的**展开其余**控件显示,而关闭并重新打开整个 Workspace 后会恢复为五条。从 Workspace 行创建 Session 时会先打开该分组,使 Session 状态到达后新行保持可见。Workspace 列表基线就绪后,浏览器持久化的展开状态与 Session 顺序记录只保留当前 Workspace id、Ungrouped 和单列表记账。视图选项把分组方式和每个记账各自的一份浏览器持久化 Session 顺序放在一起:真实 Workspace 从 `WorkspaceView.sessionIds` 初始化,Ungrouped 和跨 Workspace 的单列表则从最近更新时间顺序初始化。**手动排序**和**最近更新**在两种呈现方式下都可用。进入最近更新时会执行一次完整的时间排序,后续 user prompt 或 steer 会将对应 Session 置顶一次;进入手动排序则保留所有当前位置并停用后续置顶。两种模式下的拖拽都会编辑当前顺序;真实 Workspace 在手动模式下的拖拽还会更新 Host Session 记账,而 Ungrouped 和单列表因没有单一 Workspace 记账,其顺序始终只保存在浏览器本地。单列表没有父级层次,因此不显示空的左侧状态槽;Session 存在可见状态时仍保留该槽。无论采用哪种 Session 顺序,Workspace 拖拽顺序都由 Host 持久化。
折叠搜索是视图和添加操作旁的一枚区头按钮。激活后,输入框会扩展并占据区头;点击外部只会收起经清除首尾空白后为空的查询,而清除控件总会重置并收起搜索。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 折叠搜索是视图和添加操作旁的一枚区头按钮。在轨道中,添加和搜索会渲染为沿外壳共用横向进入路径移动的 36px 控件。激活搜索后,输入框会扩展并占据区头;点击外部只会收起经清除首尾空白后为空的查询,而清除控件总会重置并收起搜索。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。不同的规范化路径即使 basename 和显示标题相同,仍会作为由 id 区分的独立 Workspace;侧边栏的悬停详情会显示完整路径。每个注册各自声明一个**目录流子 slot**(`single` kind`conversation.hero.workspace.directoryFlow``sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **添加工作区…** 操作仅在当前界面的 slot 被占用时渲染(每次菜单渲染读取占用状态;slot 为空意味着该组合没有目录选择能力——seam 文档化的无流程默认行为,此时侧边栏区头直接不渲染添加按钮,而非留下一个点了没反应的按钮)。本包持有触发与接纳:占用方通过 slot 的属主交互约定(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。空白的「新会话」行只是占位符:不渲染行菜单和时间标签(其中还没有发生任何事),重命名、fork 和归档都从首条提示词落地后才可用。 该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。不同的规范化路径即使 basename 和显示标题相同,仍会作为由 id 区分的独立 Workspace;侧边栏的悬停详情会显示完整路径。每个注册各自声明一个**目录流子 slot**(`single` kind`conversation.hero.workspace.directoryFlow``sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **添加工作区…** 操作仅在当前界面的 slot 被占用时渲染(每次菜单渲染读取占用状态;slot 为空意味着该组合没有目录选择能力——seam 文档化的无流程默认行为,此时侧边栏区头直接不渲染添加按钮,而非留下一个点了没反应的按钮)。本包持有触发与接纳:占用方通过 slot 的属主交互约定(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。空白的「新会话」行只是占位符:不渲染行菜单和时间标签(其中还没有发生任何事),重命名、fork 和归档都从首条提示词落地后才可用。
@@ -240,11 +240,13 @@
/* Rail variant (own .rail class from the wide owner prop — the region never /* Rail variant (own .rail class from the wide owner prop — the region never
reads the shell's class names): the two icon controls stack as 36x36 reads the shell's class names): the two icon controls stack as 36x36
circles matching the shell's rail rhythm. */ circles matching the shell's rail rhythm. Both use the rail's shared base
left anchor so the outer shell can translate the whole region uniformly. */
.rail .sectionHeader { .rail .sectionHeader {
gap: 0; gap: 0;
padding-left: 0; padding-left: 0;
margin-bottom: 12px; margin-bottom: 12px;
justify-content: flex-start;
} }
.rail .headerActions { .rail .headerActions {
@@ -3,11 +3,11 @@
* `sidebar.workspaces` hole: section header (title + view options + add * `sidebar.workspaces` hole: section header (title + view options + add
* workspace), search, the grouped tree or flat list, and the workspace * workspace), search, the grouped tree or flat list, and the workspace
* dialogs. Wide state renders the full browser; rail state renders the two * dialogs. Wide state renders the full browser; rail state renders the two
* region icons (search / add workspace), each requesting shell expansion * region icons (search / add workspace) as 36px controls on the shell's shared
* through the owner share. Adding is the header button's one action, so it * rail entry path, each requesting expansion through the owner share. Adding
* raises the directory flow with no menu in between; the flow and its error * is the header button's one action, so it raises the directory flow with no
* dialog live in WorkspacePicker (same package — direct composition, no slot * menu in between; the flow and its error dialog live in WorkspacePicker
* between them). * (same package — direct composition, no slot between them).
*/ */
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
@@ -107,4 +107,10 @@ describe('WorkspaceBrowser.module.css list', () => {
expect(rowDeclarations('.sessionRow.selected')?.get('background')) expect(rowDeclarations('.sessionRow.selected')?.get('background'))
.toBe('var(--dsw-alias-interactive-bg-hover)') .toBe('var(--dsw-alias-interactive-bg-hover)')
}) })
it('pins both rail controls to the shared left anchor during the column slide', () => {
expect(declarations('.rail .sectionHeader')?.get('justify-content')).toBe('flex-start')
expect(declarations('.rail .iconButton')?.get('width')).toBe('36px')
expect(declarations('.rail .search')?.get('width')).toBe('36px')
})
}) })