From d907c8475fd1653c7b97596fd7a2753d9fa57957 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 4 Aug 2026 16:13:52 +0800 Subject: [PATCH 1/7] fix(web): keep the input card in place across view tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer seat is one node laid out against two different edges: in Chat it is a sticky child of the column's scroller and rides its content box, which a space-consuming scrollbar shortens, while a view declaring a composer overlay gets an absolutely positioned seat against the padding box, which no bar reduces. With the transcript scrolling the two tabs disagreed by the bar's width, so the centred input card moved 4px sideways on every switch — and the same 4px inside Chat when a growing transcript began to scroll. The column now reserves its scrollbar gutter unconditionally and states the overlay branch a scroll container on the same axes, so both states measure against the same width. --- ...-composer-tab-gutter-reservation.i18n.yaml | 6 + ...6-08-04-composer-tab-gutter-reservation.md | 50 +++ ...8-04-composer-tab-gutter-reservation.zh.md | 50 +++ apps/web/tests/composer-tab-geometry.e2e.ts | 388 ++++++++++++++++++ .../geometry.expected.md | 37 ++ apps/web/tsconfig.json | 1 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../skeleton/ConversationRoot.module.css | 22 +- tsconfig.host.json | 1 + 11 files changed, 558 insertions(+), 5 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md create mode 100644 apps/web/tests/composer-tab-geometry.e2e.ts create mode 100644 apps/web/tests/snapshots/composer-tab-geometry/geometry.expected.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml new file mode 100644 index 0000000000..176c649ecd --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md +2026-08-04-composer-tab-gutter-reservation.md: f9a0c346c0c023fe2602486376b9794781f824db +2026-08-04-composer-tab-gutter-reservation.zh.md: 67a284ec0a12e464ad6f1dc17e954bdebd28e80d diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md new file mode 100644 index 0000000000..f9a0c346c0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md @@ -0,0 +1,50 @@ +# Agent Note: The conversation column reserves one scrollbar gutter for every view + +Status: implemented + +English | [中文](2026-08-04-composer-tab-gutter-reservation.zh.md) + +## Problem + +The composer seat is one node in one place in the tree, and it was laid out against a different edge depending on which view tab was shown. + +In Chat it is a sticky CHILD of the column's scroller (`[data-conversation-scroll]`), so it rides that scroller's content box — the box a space-consuming scrollbar shortens by the bar's width. A view that declares `data-conversation-composer-overlay`, which Trajectory does, moves the column's scrolling into the view itself: the branch keyed on that attribute left the scroller `overflow: hidden` and positioned the seat absolutely, against the padding box, which no scrollbar reduces. + +So for as long as the transcript overflowed — the ordinary state of any session with history — the two tabs disagreed by exactly the bar's width. The input card is centred, so switching tabs moved it 4px sideways on an 8px bar, and its right-hand clearance changed by the full 8. The same displacement appeared inside Chat alone at the moment a growing transcript started to scroll, and again between the hero phase and the first scrolling turn. + +## 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`. + +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. + +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 glyph-layer note](2026-07-31-composer-glyph-layer-tracks-the-textarea.md) — so a reservation left on a hidden box would hold in Chromium and silently not in Safari. + +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. + +## Alternatives considered + +**Inset the overlay seat by the bar's width.** The narrow reading of the bug — the two states differ by 8px, so subtract 8px from one. Rejected because the number is the engine's, not ours: the WebKit path draws the sheet's 8px bar, the Firefox path draws whatever `scrollbar-width: thin` resolves to, and a hardcoded inset would line the two states up in Chromium while drifting everywhere else. The gutter asks the engine to reserve its own bar's width, whatever that is. + +**Keep `overflow: hidden` and add `scrollbar-gutter: stable` alone.** The one-line version. It fixes the visible symptom on the engine the browser lane runs, and leaves it in place on Safari, with no test failing anywhere — the failure mode the second half of the change exists to prevent. + +**Move the composer seat out of the scroller in Chat too, making the overlay geometry the only geometry.** This deletes the difference at its root rather than reconciling it, and gives up a deliberate property: the sticky seat sits inside the scroll flow, so a wheel over the composer moves the transcript ([sticky composer](2026-07-29-sticky-composer-conversation-scroll.md)), and the fade mask above it is painted by the seat's own background. Both are owned behavior with their own coverage; rebuilding them to remove 8px of asymmetry is the larger change, not the smaller one. + +**Pad the column by the bar's width instead of reserving a gutter.** Padding applies whether or not a bar is present, so it costs the width unconditionally in every state, and it pins a value in the stylesheet that the engine picks at layout time. Rejected for the same reason the sidebar list rejected it. + +## 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. +- 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 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. + +## Testing + +`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 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. diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md new file mode 100644 index 0000000000..67a284ec0a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md @@ -0,0 +1,50 @@ +# Agent Note: 会话列为每个视图预留同一条滚动条槽 + +Status: implemented + +[English](2026-08-04-composer-tab-gutter-reservation.md) | 中文 + +## 问题 + +composer 座位在组件树中只有一个节点、一个位置,但它究竟对齐到哪条边,取决于当前展示的是哪个视图标签页。 + +在 Chat 中它是会话列滚动容器(`[data-conversation-scroll]`)的 sticky **子元素**,因而依附于该容器的 content box——而占布局宽度的滚动条会把这个盒子收窄一条滚动条的宽度。声明了 `data-conversation-composer-overlay` 的视图(Trajectory 即是其一)会把会话列的滚动搬进视图自身:以该属性为条件的那条分支把滚动容器留作 `overflow: hidden`,并把座位改为绝对定位——对齐的是 padding box,而滚动条从不收窄这个盒子。 + +于是只要对话记录超出一屏——任何带历史的会话的常态——两个标签页就恰好相差一条滚动条的宽度。输入卡片是居中的,因此在 8px 的滚动条下切换标签页会让它横向移动 4px,而右侧留白整整变化 8px。同一位移也出现在 Chat 内部:对话增长到开始滚动的那一刻,以及从 hero 态进入第一个可滚动轮次时。 + +## 决策 + +`.scrollBody` 无条件声明 `scrollbar-gutter: stable`,overlay 分支则把同一个盒子在两个轴向上都声明为滚动容器——`overflow-x: hidden; overflow-y: auto`——而不再是 `overflow: hidden`。 + +这两半是同一处改动。预留使两种状态依附于同一个宽度;把 overlay 分支声明为滚动容器,才使这条预留真正抵达它。选 `stable` 而非 `auto`,是因为 `auto` 只在盒子确实溢出时才预留,而"溢出与否"恰恰就是两个标签页之间的那点差别——`auto` 的写法只是把缺陷重述一遍,并不能修掉它。 + +overlay 状态是一个没有任何东西会去滚动它的滚动容器:视图把它填满(`flex: 1 1 0`,且自带裁剪),座位不在常规流中,因此没有任何手势与裁剪行为发生变化。变化的是引擎会认哪些声明。WebKit 对 `overflow-y: auto` 的盒子应用 `scrollbar-gutter`,对 hidden 的盒子则忽略它——这是在本应用 composer 自身的图层上实测所得,并记录于 [composer 字形层记录](2026-07-31-composer-glyph-layer-tracks-the-textarea.md)——所以把预留留在一个 hidden 盒子上,会在 Chromium 上成立,在 Safari 上悄无声息地不成立。 + +横向轴是显式声明的,而不是交给推导:单轴滚动的盒子会把另一轴的 `visible` 计算为 `auto`,于是只要某个视图的内容第一次伸出列外,它就会长出自己的横向滚动条。 + +这条预留之所以值回它的代价,前提是滚动条在这里确实占布局空间——这并非浏览器的默认行为,而是本客户端的选择:ui-theme 的样式表给 `::-webkit-scrollbar` 声明了宽度([滚动条主题化](2026-07-28-themed-scrollbars-and-reserved-gutter.md)),侧边栏的会话列表也正是出于同一原因预留了自己的滚动条槽。 + +## 曾考虑的替代方案 + +**把 overlay 座位按滚动条宽度内缩。** 这是对该缺陷最窄的一种解读——两种状态差 8px,那就从一侧减去 8px。之所以否决,是因为这个数字属于引擎而不属于我们:WebKit 路径绘制样式表里的 8px 滚动条,Firefox 路径绘制 `scrollbar-width: thin` 解析出的宽度,硬编码的内缩会让两种状态在 Chromium 上对齐、在别处继续漂移。滚动条槽是请引擎按它自己那条滚动条的宽度去预留,无论那是多少。 + +**保留 `overflow: hidden`,只加 `scrollbar-gutter: stable`。** 单行版本。它能在浏览器车道所用的引擎上修掉可见症状,却把症状原封不动留在 Safari 上,而且任何测试都不会失败——这正是改动的后一半所要防的失效模式。 + +**让 Chat 的 composer 座位也移出滚动容器,使 overlay 的几何成为唯一的几何。** 这是从根上删掉差异,而不是调和它,代价是放弃一项刻意的性质:sticky 座位位于滚动流之内,因此在 composer 上滚轮会带动对话记录([sticky composer](2026-07-29-sticky-composer-conversation-scroll.md)),其上方的渐隐遮罩也由座位自身的背景绘制。两者都是有主、有覆盖的既有行为;为了消除 8px 的不对称而重建它们,是更大的改动而非更小的。 + +**给会话列加上一条滚动条宽度的内边距,而不是预留滚动条槽。** 内边距无论是否存在滚动条都会生效,因此在每种状态下都无条件付出这份宽度,而且它把一个由引擎在布局期决定的值钉死在样式表里。否决理由与侧边栏列表当初否决它时相同。 + +## 后果 + +- Chat 的内容列永久变窄 8px——hero 态与对话记录尚短、根本不绘制滚动条时同样如此。这就是这笔交易:以最宽的列换取卡片在任何内容高度下都只有一个位置。 +- 一条声明覆盖三种切换,因为这三者本就是同一个差异:Chat ↔ Trajectory、Chat 内部的短对话 ↔ 可滚动对话,以及 hero ↔ 第一个可滚动轮次。 +- overlay 状态现在是一个滚动容器。今天其中没有任何内容会溢出;将来若有视图允许自身内容超出会话列,这个盒子会滚动而不是裁剪,那个视图就需要像 Trajectory 视图那样自带裁剪。 +- 提交的 golden 记录了预留的带宽,因此样式表中 `::-webkit-scrollbar` 宽度的变化——决定这条预留有多宽的那个值——会在本场景中与在侧边栏场景中一样,以可评审的 diff 形式出现。 + +## 测试 + +`apps/web/tests/composer-tab-geometry.e2e.ts` 在两个标签页下测量输入卡片的矩形,分别取卡片处于宽度上限的视口与卡片随列收缩的视口,并断言这两个矩形是同一个矩形。只有真实引擎能报告这件事:jsdom 给每个元素的盒子尺寸都是零,也没有滚动条,因此单元测试只能断言那些声明存在,无法断言两种状态落在同一位置。出于同一原因,本次没有附带读取 CSS 文本的单元测试——它只会把声明复述一遍,并不会补上浏览器车道尚未确立的事实。 + +该场景启动 chromium 时去掉了 Playwright 默认的 `--hide-scrollbars`,这一点是承重的:带上该参数时滚动条不占任何布局宽度,两个标签页在改动前后同样一致,文件中的每一处比较都会空洞地通过。实测:带上该参数时,改动前的层叠让两侧带宽都是 0;去掉它则是 8 与 0。 + +随后,改动前的层叠会被注入页面——滚动容器上 `scrollbar-gutter: auto`,overlay 分支上 `overflow: hidden`——并在其下测量同样的两个标签页,这正是把"卡片确实没动"与"标签页切换根本没到达布局"区分开的那一步。它把上报的症状复现为一个数字:每条边 4px,恰是 8px 带宽的一半。golden 把这份对照与修复后的状态并排记录,因此 fixture 承载的是这次改动所消除的那个差值,而不仅仅是它的缺席。 diff --git a/apps/web/tests/composer-tab-geometry.e2e.ts b/apps/web/tests/composer-tab-geometry.e2e.ts new file mode 100644 index 0000000000..a7206e0bbd --- /dev/null +++ b/apps/web/tests/composer-tab-geometry.e2e.ts @@ -0,0 +1,388 @@ +// Web e2e scenario: the input card holds one horizontal position across the +// Chat and Trajectory tabs. +// +// The composer seat is the same node in both tabs, but it measures itself +// against a different edge in each (see +// packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css). +// In Chat it is a sticky CHILD of the column's scroller, so it rides that +// scroller's content box — the box a space-consuming scrollbar shortens. A view +// that opts into a composer overlay (`data-conversation-composer-overlay`, which +// Trajectory declares and which moves the column's own scrolling into the view) +// gets an absolutely positioned seat instead, laid out against the padding box, +// which the scrollbar never reduces. +// +// So the two tabs disagreed by exactly the bar's width for as long as the +// transcript overflowed: the card jumped sideways on every tab switch, and +// inside Chat alone at the moment a growing transcript started to scroll. The +// column now reserves the gutter unconditionally (`scrollbar-gutter: stable`) +// and states the overlay branch as a scroll container on the same axes, so both +// edges are the same edge. +// +// 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 +// can assert the declarations exist but not that the two states land in the same +// place. What is asserted here is the user-visible fact — the card does not move +// — measured as the distance between the two tabs' card rectangles. +// +// The browser is launched WITHOUT Playwright's default `--hide-scrollbars`, +// 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 before +// this change as much as after it and every comparison below holds vacuously — +// measured: the pre-fix cascade leaves both tabs' bands at 0 there, 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, +// and a bar that occupies layout space is what the product actually draws. +// +// The scenario runs that pre-fix cascade in the page — `scrollbar-gutter: auto` +// on the scroller, `overflow: hidden` on the overlay branch — and measures the +// same two tabs through it, which is what keeps the equal rectangles above from +// being explained by a tab switch that never reached the layout. It is the +// reported symptom as a number: the card moves 4px, half the 8px band, on each +// edge. +// +// 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. +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createChatScrollFixture } from './chat-scroll-fixture.ts' +import { + assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, + webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-tab-geometry', import.meta.url)) +/** + * Committed golden of where the input card sits in each tab, at a wide viewport + * (card at its width cap) and a narrow one (card shrinking with the column). + * + * 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 + * 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 + * the bar's width when it does not — including under the control, so the golden + * carries the difference the fix removes rather than only its absence. + */ +const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md') +const MODE = webSnapshotMode() + +/** Long enough that the transcript overflows the lane's 1000px viewport; the scenario asserts the overflow rather than trusting it. */ +const FIXTURE = createChatScrollFixture({ + markerPrefix: 'TAB_GEOMETRY', + title: 'COMPOSER_TAB_GEOMETRY long session', + turns: 24, +}) +const SEED_ID = 'composer-tab-geometry-web-e2e' + +/** Viewport widths the scenario measures at: the card capped, and the card shrinking with the column. */ +const WIDE_VIEWPORT = { width: 1680, height: 1000 } +const NARROW_VIEWPORT = { width: 900, height: 1000 } + +/** + * The pre-fix cascade, injected into the page: the reservation dropped and the + * overlay branch back to a hidden box. `!important` beats the module rules + * without a rebuild, and the id lets the control be lifted again in the same + * session. + */ +const CONTROL_STYLE_ID = 'composer-tab-geometry-control' +const CONTROL_CSS = ` +[data-conversation-scroll] { scrollbar-gutter: auto !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. */ +interface TabMetrics { + /** Resolved `scrollbar-gutter` on the column's scroller. */ + gutter: string + /** Resolved `overflow-x`: `hidden` in both states, so neither grows a horizontal bar. */ + overflowX: string + /** Resolved `overflow-y`: `auto` in both states, which is the form WebKit honours the gutter on. */ + overflowY: string + /** Border-box width minus client width: the space the scrollbar takes out of the content area. */ + band: number + /** True when the column's scroller actually scrolls — only Chat does. */ + scrolls: boolean + /** Left edge of the input card in viewport coordinates. */ + cardLeft: number + /** Right edge of the input card. */ + cardRight: number + /** Width of the input card, capped at the composer card max width. */ + cardWidth: number +} + +/** One tab's metrics beside the other's, plus the distances between them. */ +interface TabComparison { + chat: TabMetrics + trajectory: TabMetrics + /** Distance between the two tabs' card left edges: 0 when the card holds its position. */ + leftShift: number + /** Distance between the two tabs' card right edges. */ + rightShift: number + /** Difference between the two tabs' card widths. */ + widthShift: number +} + +/** + * Measure the column scroller and the input card in the tab currently shown. + * @param page - the page under test. + * @returns the scroller's resolved overflow style and the card's rectangle. + */ +function measureTab(page: Page): Promise { + return page.evaluate(() => { + const host = document.querySelector('[data-conversation-scroll]') + if (host === null) throw new Error('conversation column scroller not in the DOM') + const card = host.querySelector('[data-composer-seat] [data-composer-card]') + if (card === null) throw new Error('no input card inside the composer seat') + const style = getComputedStyle(host) + const hostRect = host.getBoundingClientRect() + const cardRect = card.getBoundingClientRect() + return { + gutter: style.scrollbarGutter, + overflowX: style.overflowX, + overflowY: style.overflowY, + band: hostRect.width - host.clientWidth, + scrolls: host.scrollHeight > host.clientHeight, + cardLeft: cardRect.left, + cardRight: cardRect.right, + cardWidth: cardRect.width, + } + }) +} + +/** + * Show one tab and wait for the view that owns it to be laid out. + * @param page - the page under test. + * @param tab - the tab to show. + */ +async function showTab(page: Page, tab: 'Chat' | 'Trajectory'): Promise { + await page.getByRole('tab', { name: tab, exact: true }).click() + if (tab === 'Trajectory') await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 }) + else await page.locator('[data-conversation-scroll] [data-chat-anchor-key]').first().waitFor({ timeout: 30_000 }) + // Both measurements are taken after a paint, so a rectangle read mid-transition + // cannot be reported as a shift the cascade did not cause. + await page.evaluate(() => new Promise((settle) => { + requestAnimationFrame(() => { requestAnimationFrame(() => { settle() }) }) + })) +} + +/** + * Measure both tabs and the distances between them, leaving Chat shown. + * @param page - the page under test. + * @returns each tab's metrics and the card's displacement between them. + */ +async function compareTabs(page: Page): Promise { + await showTab(page, 'Chat') + const chat = await measureTab(page) + await showTab(page, 'Trajectory') + const trajectory = await measureTab(page) + await showTab(page, 'Chat') + return { + chat, + trajectory, + leftShift: Math.abs(trajectory.cardLeft - chat.cardLeft), + rightShift: Math.abs(trajectory.cardRight - chat.cardRight), + widthShift: Math.abs(trajectory.cardWidth - chat.cardWidth), + } +} + +/** + * Run the pre-fix cascade in the page for one measurement, then lift it. + * @param page - the page under test. + * @returns the comparison as the column laid out before this change. + */ +async function compareTabsWithoutReservation(page: Page): Promise { + await page.evaluate(({ id, css }) => { + const style = document.createElement('style') + style.id = id + style.textContent = css + document.head.append(style) + }, { id: CONTROL_STYLE_ID, css: CONTROL_CSS }) + try { + return await compareTabs(page) + } finally { + await page.evaluate((id) => { document.getElementById(id)?.remove() }, CONTROL_STYLE_ID) + } +} + +/** + * Open the seeded session from the sidebar search. + * + * Cold summaries carry the temp workspace's basename, so the persisted first + * message is the stable identity to search for, and the query itself drives the + * lazy content-index reconciliation. Hand-rolled polling because `expect.poll` + * is test-scoped and this runs in `beforeAll`. + * @param page - the page under test. + */ +async function openSeededSession(page: Page): Promise { + const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true }) + await search.fill(FIXTURE.markers.user(1)) + const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') + const deadline = Date.now() + 60_000 + for (;;) { + if (await results.count() === 1) break + if (Date.now() > deadline) throw new Error('seeded session never appeared in the sidebar search results') + await page.waitForTimeout(200) + } + await results.click() +} + +/** + * Render the golden body. + * @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 control - comparison at the wide viewport with the reservation removed. + * @returns the golden body, without a trailing newline. + */ +function renderGeometry(wide: TabComparison, narrow: TabComparison, control: TabComparison): string { + const section = (name: string, comparison: TabComparison): string[] => [ + `## ${name}`, + '', + `- Chat: scrollbar-gutter ${comparison.chat.gutter}, overflow ${comparison.chat.overflowX}/${comparison.chat.overflowY}`, + `- Chat scroller scrolls: ${String(comparison.chat.scrolls)}`, + `- Chat reserved band: ${String(comparison.chat.band)}px`, + `- Trajectory: scrollbar-gutter ${comparison.trajectory.gutter}, overflow ${comparison.trajectory.overflowX}/${comparison.trajectory.overflowY}`, + `- Trajectory scroller scrolls: ${String(comparison.trajectory.scrolls)}`, + `- Trajectory reserved band: ${String(comparison.trajectory.band)}px`, + `- input card left edge moves between tabs: ${String(comparison.leftShift)}px`, + `- input card right edge moves between tabs: ${String(comparison.rightShift)}px`, + `- input card width changes between tabs: ${String(comparison.widthShift)}px`, + '', + ] + return [ + '# Input card position across the Chat and Trajectory tabs', + '', + ...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('Wide viewport, reservation removed in the page (control)', control), + ].join('\n').trimEnd() +} + +describe('web e2e: input card position across view tabs', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, FIXTURE.log, SEED_ID) + // Scrollbars must take layout space here or the scenario proves nothing; + // see the file header for the measurement behind dropping this argument. + browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] }) + page = await newEnglishPage(browser, WIDE_VIEWPORT.height) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await openSeededSession(page) + await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 }) + await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false }).last() + .waitFor({ timeout: 30_000 }) + }, 180_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('reserves the same gutter in both tabs while the transcript scrolls', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-band')) + await page.setViewportSize(WIDE_VIEWPORT) + // Vacuity guard, in two parts. A transcript that does not overflow gives + // Chat no scrollbar, and a hidden or overlaid bar gives it no width; either + // would make the tabs agree without the reservation doing anything. + await expect.poll(async () => (await measureTab(page)).scrolls, { timeout: 10_000 }).toBe(true) + const comparison = await compareTabs(page) + expect(comparison.chat.band).toBeGreaterThan(0) + // The reservation reaches both states, which is the whole change: the same + // band, on a box that scrolls and on one that only holds a view. + expect(comparison.chat.gutter).toBe('stable') + expect(comparison.trajectory.gutter).toBe('stable') + expect(comparison.trajectory.band).toBe(comparison.chat.band) + // Declared as a scroll container on both axes rather than left to compute: + // `overflow: hidden` would drop the reservation in WebKit, and a `visible` + // horizontal axis computes to `auto` beside a scrolling one. + expect(comparison.trajectory.overflowY).toBe('auto') + expect(comparison.trajectory.overflowX).toBe('hidden') + // Only Chat scrolls this box; the Trajectory view owns its own scrollers. + expect(comparison.trajectory.scrolls).toBe(false) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('holds the input card in place when the tab changes', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-wide')) + await page.setViewportSize(WIDE_VIEWPORT) + const comparison = await compareTabs(page) + // The reported symptom as a number. At this viewport the card sits at its + // width cap, so the pre-fix shift showed up as a centring difference — half + // the band on each edge — rather than as a width change. + expect(comparison.leftShift).toBe(0) + expect(comparison.rightShift).toBe(0) + expect(comparison.widthShift).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('holds the input card in place at a viewport where it shrinks with the column', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-narrow')) + await page.setViewportSize(WIDE_VIEWPORT) + const capped = await measureTab(page) + await page.setViewportSize(NARROW_VIEWPORT) + const comparison = await compareTabs(page) + // The other geometry, and a different failure: below the cap the card takes + // the column's width, so an unreserved gutter changed its WIDTH by the whole + // band instead of shifting it by half. Asserted against the capped + // measurement rather than against the cap's pixel value, which belongs to + // the stylesheet. + expect(comparison.chat.cardWidth).toBeLessThan(capped.cardWidth) + expect(comparison.leftShift).toBe(0) + expect(comparison.rightShift).toBe(0) + expect(comparison.widthShift).toBe(0) + await page.setViewportSize(WIDE_VIEWPORT) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('moves the card again once the reservation is removed in the page', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-control')) + await page.setViewportSize(WIDE_VIEWPORT) + // The control: without it, equal rectangles could also mean the tab switch + // never reached the layout. Under the pre-fix cascade the Chat scroller keeps + // its bar and the Trajectory branch goes back to a hidden box with none, and + // the card moves by half the band on each edge. + const comparison = await compareTabsWithoutReservation(page) + expect(comparison.chat.gutter).toBe('auto') + expect(comparison.chat.band).toBeGreaterThan(0) + expect(comparison.trajectory.band).toBe(0) + expect(comparison.leftShift).toBe(comparison.chat.band / 2) + expect(comparison.rightShift).toBe(comparison.chat.band / 2) + // Restoring the sheet restores the fix, so the control cannot leak into the + // remaining measurements. + const restored = await compareTabs(page) + expect(restored.leftShift).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('matches the committed tab geometry golden', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-golden')) + await page.setViewportSize(WIDE_VIEWPORT) + const wide = await compareTabs(page) + await page.setViewportSize(NARROW_VIEWPORT) + const narrow = await compareTabs(page) + await page.setViewportSize(WIDE_VIEWPORT) + const control = await compareTabsWithoutReservation(page) + await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(wide, narrow, control), MODE) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('commits exactly the fixtures it reads', async () => { + // The seeded session is generated in-process, so the geometry golden is the + // whole inventory. + await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md']) + }) + + it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => { + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + }) +}) diff --git a/apps/web/tests/snapshots/composer-tab-geometry/geometry.expected.md b/apps/web/tests/snapshots/composer-tab-geometry/geometry.expected.md new file mode 100644 index 0000000000..a4f4319c93 --- /dev/null +++ b/apps/web/tests/snapshots/composer-tab-geometry/geometry.expected.md @@ -0,0 +1,37 @@ +# Input card position across the Chat and Trajectory tabs + +## Wide viewport (1680px, card at its cap) + +- Chat: scrollbar-gutter stable, overflow auto/auto +- Chat scroller scrolls: true +- Chat reserved band: 8px +- Trajectory: scrollbar-gutter stable, overflow hidden/auto +- Trajectory scroller scrolls: false +- Trajectory reserved band: 8px +- input card left edge moves between tabs: 0px +- input card right edge moves between tabs: 0px +- input card width changes between tabs: 0px + +## Narrow viewport (900px, card shrinking with the column) + +- Chat: scrollbar-gutter stable, overflow auto/auto +- Chat scroller scrolls: true +- Chat reserved band: 8px +- Trajectory: scrollbar-gutter stable, overflow hidden/auto +- Trajectory scroller scrolls: false +- Trajectory reserved band: 8px +- input card left edge moves between tabs: 0px +- input card right edge moves between tabs: 0px +- input card width changes between tabs: 0px + +## Wide viewport, reservation removed in the page (control) + +- Chat: scrollbar-gutter auto, overflow auto/auto +- Chat scroller scrolls: true +- Chat reserved band: 8px +- Trajectory: scrollbar-gutter auto, overflow hidden/hidden +- Trajectory scroller scrolls: false +- Trajectory reserved band: 0px +- input card left edge moves between tabs: 4px +- input card right edge moves between tabs: 4px +- input card width changes between tabs: 0px diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index ecb4f0db6c..112731204b 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -61,6 +61,7 @@ "tests/chat-scroll-contract.e2e.ts", "tests/chat-long-interactions.e2e.ts", "tests/chat-continuous-conversation.e2e.ts", + "tests/composer-tab-geometry.e2e.ts", "tests/complex-history.perf.ts" ], "references": [ diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index bf1e21a541..654eac8ede 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 7d3a4b5fe07cc8858c2f2059e6f65b6e27602b4d -README.zh.md: d93af91381157bb4e8e4b6a14ad00edecd505246 +README.md: ce728fb8b35d9813050a00cf9fa6a4f268820fea +README.zh.md: 7e73a2b4a4d19d81ea62701dd71ed68f32492c48 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 7d3a4b5fe0..ce728fb8b3 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -6,7 +6,7 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (gro Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. The disclosure renders the checkpoint's `compact/summary` provenance; when that event is outside the loaded window, the row remains visible but non-expandable. The framed checkpoint payload is model-facing and never renders. -The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. +The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index d93af91381..7e73a2b4a4 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -6,7 +6,7 @@ 压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。展开内容来自检查点溯源的 `compact/summary`;该事件位于已加载窗口之外时,标记仍然可见但不可展开。面向模型的带框检查点载荷绝不渲染。 -常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 +常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: `);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包(package)自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。 diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 1a83efb551..b2ab6d558a 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -182,6 +182,17 @@ flex-direction: column; min-height: 0; overflow-y: auto; + /* One reserved gutter for every view, because the composer seat takes its + horizontal geometry from two different edges: in Chat it is a sticky CHILD + of this scroller and so rides the content box, which the scrollbar + shortens, while under a view's composer overlay it is positioned against + the padding box, which the scrollbar never reduces. `auto` would reserve + only while this box overflows, which is precisely the difference between + the two states, so the input card would move sideways by the bar's width + on every tab switch — and inside Chat alone the moment a growing + transcript starts to scroll. `stable` makes both edges the same edge at + every content height. */ + scrollbar-gutter: stable; } .root[data-phase='active'] .viewArea { @@ -212,7 +223,16 @@ ownership of the seat geometry and its active-phase precedence. */ .scrollBody:has([data-conversation-composer-overlay]) { position: relative; - overflow: hidden; + /* Still a box nothing scrolls out of — the view fills it and the seat is out + of flow — but declared as a scroll container on the same axes as the Chat + state instead of `overflow: hidden`, so the reservation above reaches this + state too: WebKit honours `scrollbar-gutter` on an `overflow-y: auto` box + and ignores it on a hidden one (measured for the composer's own layers, + see InputBar.module.css). The horizontal axis is declared rather than left + to compute, because a box that scrolls on one axis computes `visible` on + the other to `auto` and would grow a horizontal bar of its own. */ + overflow-x: hidden; + overflow-y: auto; } .scrollBody:has([data-conversation-composer-overlay]) > .viewArea { diff --git a/tsconfig.host.json b/tsconfig.host.json index bfc4f898e8..8205019f89 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -48,6 +48,7 @@ "apps/web/tests/chat-scroll-contract.e2e.ts", "apps/web/tests/chat-long-interactions.e2e.ts", "apps/web/tests/chat-continuous-conversation.e2e.ts", + "apps/web/tests/composer-tab-geometry.e2e.ts", "apps/web/tests/complex-history.perf.ts", "apps/web/stress-tests/reasoning-chunks.stress.ts", "apps/cli/tests/**/*.ts", From 1098dc983611a88e445e4511da019b0bc2fd815a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 4 Aug 2026 16:21:55 +0800 Subject: [PATCH 2/7] docs(web): cite the surviving composer scrollport note for the WebKit gutter measurement --- .../2026-08-04-composer-tab-gutter-reservation.i18n.yaml | 4 ++-- .../bug-fix/2026-08-04-composer-tab-gutter-reservation.md | 2 +- .../bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md | 2 +- .../src/client/skeleton/ConversationRoot.module.css | 5 +++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml index 176c649ecd..c4a8fd6268 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md -2026-08-04-composer-tab-gutter-reservation.md: f9a0c346c0c023fe2602486376b9794781f824db -2026-08-04-composer-tab-gutter-reservation.zh.md: 67a284ec0a12e464ad6f1dc17e954bdebd28e80d +2026-08-04-composer-tab-gutter-reservation.md: 3b28c35c1f11676e41cabde76d1b0d16c688f034 +2026-08-04-composer-tab-gutter-reservation.zh.md: 26e8b6bff73a01e6518f3918d201330c1d029876 diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md index f9a0c346c0..3b28c35c1f 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md @@ -18,7 +18,7 @@ So for as long as the transcript overflowed — the ordinary state of any sessio 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. -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 glyph-layer note](2026-07-31-composer-glyph-layer-tracks-the-textarea.md) — so a reservation left on a hidden box would hold in Chromium and silently not in Safari. +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 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. diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md index 67a284ec0a..26e8b6bff7 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md @@ -18,7 +18,7 @@ composer 座位在组件树中只有一个节点、一个位置,但它究竟 这两半是同一处改动。预留使两种状态依附于同一个宽度;把 overlay 分支声明为滚动容器,才使这条预留真正抵达它。选 `stable` 而非 `auto`,是因为 `auto` 只在盒子确实溢出时才预留,而"溢出与否"恰恰就是两个标签页之间的那点差别——`auto` 的写法只是把缺陷重述一遍,并不能修掉它。 -overlay 状态是一个没有任何东西会去滚动它的滚动容器:视图把它填满(`flex: 1 1 0`,且自带裁剪),座位不在常规流中,因此没有任何手势与裁剪行为发生变化。变化的是引擎会认哪些声明。WebKit 对 `overflow-y: auto` 的盒子应用 `scrollbar-gutter`,对 hidden 的盒子则忽略它——这是在本应用 composer 自身的图层上实测所得,并记录于 [composer 字形层记录](2026-07-31-composer-glyph-layer-tracks-the-textarea.md)——所以把预留留在一个 hidden 盒子上,会在 Chromium 上成立,在 Safari 上悄无声息地不成立。 +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 上悄无声息地不成立。 横向轴是显式声明的,而不是交给推导:单轴滚动的盒子会把另一轴的 `visible` 计算为 `auto`,于是只要某个视图的内容第一次伸出列外,它就会长出自己的横向滚动条。 diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index b2ab6d558a..eb2b412972 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -227,8 +227,9 @@ of flow — but declared as a scroll container on the same axes as the Chat state instead of `overflow: hidden`, so the reservation above reaches this state too: WebKit honours `scrollbar-gutter` on an `overflow-y: auto` box - and ignores it on a hidden one (measured for the composer's own layers, - see InputBar.module.css). The horizontal axis is declared rather than left + and ignores it on a hidden one (measured for the composer's own layers — + .agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md + cites the measurement). The horizontal axis is declared rather than left to compute, because a box that scrolls on one axis computes `visible` on the other to `auto` and would grow a horizontal bar of its own. */ overflow-x: hidden; From 60e4509c1d4239828b1a513d623f83842597dcff Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 4 Aug 2026 16:30:45 +0800 Subject: [PATCH 3/7] docs(web): trim the column's gutter comments to contract plus note link --- .../skeleton/ConversationRoot.module.css | 28 ++++++------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index eb2b412972..733e6b6ee0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -182,16 +182,10 @@ flex-direction: column; min-height: 0; overflow-y: auto; - /* One reserved gutter for every view, because the composer seat takes its - horizontal geometry from two different edges: in Chat it is a sticky CHILD - of this scroller and so rides the content box, which the scrollbar - shortens, while under a view's composer overlay it is positioned against - the padding box, which the scrollbar never reduces. `auto` would reserve - only while this box overflows, which is precisely the difference between - the two states, so the input card would move sideways by the bar's width - on every tab switch — and inside Chat alone the moment a growing - transcript starts to scroll. `stable` makes both edges the same edge at - every content height. */ + /* Reserved unconditionally: the composer seat rides this box's content box in + Chat and its padding box under a view's composer overlay, so an `auto` + gutter moves the input card sideways by the bar's width whenever the two + differ ([decision](../../../../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). */ scrollbar-gutter: stable; } @@ -223,15 +217,11 @@ ownership of the seat geometry and its active-phase precedence. */ .scrollBody:has([data-conversation-composer-overlay]) { position: relative; - /* Still a box nothing scrolls out of — the view fills it and the seat is out - of flow — but declared as a scroll container on the same axes as the Chat - state instead of `overflow: hidden`, so the reservation above reaches this - state too: WebKit honours `scrollbar-gutter` on an `overflow-y: auto` box - and ignores it on a hidden one (measured for the composer's own layers — - .agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md - cites the measurement). The horizontal axis is declared rather than left - to compute, because a box that scrolls on one axis computes `visible` on - the other to `auto` and would grow a horizontal bar of its own. */ + /* A clipping box nothing scrolls out of, stated as a scroll container on both + axes rather than `overflow: hidden`: WebKit honours the reservation above + only in the `overflow-y: auto` form, and a single-axis scroller computes + the other axis to `auto` + ([decision](../../../../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). */ overflow-x: hidden; overflow-y: auto; } From add1674ac4bd15986a1a88fdc14aad0c1484c8f1 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 5 Aug 2026 11:37:57 +0800 Subject: [PATCH 4/7] fix(client): only reuse workspace-member blank sessions for New Session The blank-session reuse scan in connectWorkspace matched on cwd alone, so a live blank session the CLI/TUI birthed at the host cwd (never accounted to any workspace) was hijacked when + was clicked on a workspace registered at that path, opening a session the sidebar shows under Ungrouped instead of the clicked workspace. Reuse now also requires membership in the workspace's sessionIds (the host's own membership rule); cwd-only matches fall through to session.create({workspaceId}). Fixes #1647 --- ...e-blank-session-reuse-membership.i18n.yaml | 6 ++++ ...orkspace-blank-session-reuse-membership.md | 29 +++++++++++++++++++ ...space-blank-session-reuse-membership.zh.md | 29 +++++++++++++++++++ packages/client/runtime/README.md | 2 +- .../runtime/src/client/workspaces/service.ts | 12 +++++--- .../runtime/tests/workspaces-service.spec.ts | 20 ++++++++++--- 6 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml new file mode 100644 index 0000000000..7514000344 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md +2026-08-05-workspace-blank-session-reuse-membership.md: 3f60c8d803bb09d6421e09886ec143296ed2b763 +2026-08-05-workspace-blank-session-reuse-membership.zh.md: 4932e85ef71a3cb2d5cad3cf9cf873460842e046 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md new file mode 100644 index 0000000000..3f60c8d803 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md @@ -0,0 +1,29 @@ +# Agent Note: Workspace New Session reuse hijacked cwd-matching unaccounted blank sessions + +Status: implemented + +English | [中文](2026-08-05-workspace-blank-session-reuse-membership.zh.md) + +## Problem + +Clicking the `+` on a Workspace group in the sidebar sometimes opened a session that the sidebar showed under Ungrouped instead of under the clicked Workspace — "entered a new session but the Workspace was not selected". The failure was specific to Workspaces registered at the directory the CLI runs from (in practice the harness checkout itself, i.e. `defaults.cwd = process.cwd()`), and appeared once a CLI-born blank session existed there. + +Root cause: `connectWorkspace`'s blank-session reuse scanned the session list mirror on `cwd` equality alone. The host's own membership rule requires **both** an id in the Workspace account (`sessionIds`) **and** a session header whose canonical cwd equals the Workspace path ([Workspace UI product flow](2026-07-25-workspace-ui-product-flow.md)); a cwd match without the account slot is exactly the Ungrouped case. The reuse scan ignored the account slot, so any **live blank** session whose cwd matched qualified — including `main-session-*` sessions the CLI/TUI/headless entry points birth at the host cwd (`session.create({})` falls back to `defaults.cwd` and never attaches to a Workspace). When such a session was live and blank (no `turn/start` yet), the next `+` click on a Workspace registered at that path reused it and navigation opened a session no grouping surface can show under that Workspace. Workspaces at other paths were unaffected because no unaccounted blank sessions accumulate there; the host-cwd Workspace accumulated one per CLI run. + +## Decision + +The reuse scan now requires workspace membership: `blank` AND `summary.cwd === workspace.path` AND `workspace.sessionIds.includes(summary.id)` AND not archived. A cwd-only match falls through to `session.create({ workspaceId })`, which attaches the fresh session so the Workspace owns it — the same arm the flow already used for "no blank session exists". + +## Alternatives considered + +**Adopt the stray instead of minting.** `session.create({ workspaceId })` could attach a cwd-matching unaccounted blank session. Rejected: silently attaching CLI-born sessions to a Workspace crosses the account boundary by surprise, and the client cannot distinguish "stray" from "the Workspace's own blank" without the membership view — which is the fix itself. + +**Attach on reuse via a new wire operation.** Requires a `workspace.attachSession` RPC in the navigation hot path and would still render the session under Ungrouped for a frame; no product need justifies the surface. + +## Consequences + +Stray blank sessions remain visible in Ungrouped (the user can still open them) but are never hijacked by a Workspace's New Session flow. A stale client mirror (Workspace frame lagging the account) can miss a reuse window and create instead — the pre-existing race behavior, already coalesced per Workspace. No host, wire, or durable-format change. + +## Testing + +`packages/client/runtime/tests/workspaces-service.spec.ts` covers the four outcomes: a member blank session is reused (no create RPC); a stray blank with matching cwd is **not** reused and a fresh accounted session is created (regression case); an archived blank is not reused; a rejected first prompt keeps a member blank eligible. The full client suite (`pnpm run test:gui`) stays green. diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md new file mode 100644 index 0000000000..4932e85ef7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md @@ -0,0 +1,29 @@ +# Agent Note:工作区新建会话复用了 cwd 匹配但未入账的空白会话 + +状态:已实现 + +[English](2026-08-05-workspace-blank-session-reuse-membership.md) | 中文 + +## 问题 + +在侧边栏某个工作区分组的 `+` 上创建会话时,有时会进入一个新会话,但侧边栏把它显示在「未分组」而不是点击的那个工作区下——「进入了新会话,但工作区没有被选中」。故障只出现在注册在 CLI 运行目录(即 `defaults.cwd = process.cwd()`,实际场景里就是 harness 检出目录本身)上的工作区,并且一旦该目录下存在 CLI 创建的空白会话就会出现。 + +根因:`connectWorkspace` 的空白会话复用扫描只按 `cwd` 相等匹配会话列表镜像。host 自己的成员规则要求**同时**满足:会话 id 在工作区账户(`sessionIds`)中,**且**会话 header 的规范化 cwd 等于工作区路径([Workspace UI product flow](2026-07-25-workspace-ui-product-flow.md));只有 cwd 匹配而没有账户槽位的恰恰就是「未分组」的情形。复用扫描忽略了账户槽位,因此任何 cwd 匹配的**在线空白**会话都会被选中——包括 CLI/TUI/headless 入口在 host cwd 创建的 `main-session-*` 会话(`session.create({})` 回退到 `defaults.cwd`,从不挂到任何工作区)。当这样的会话在线且空白(尚无 `turn/start`)时,下一次在该路径注册的工作区上点击 `+` 就会复用它,导航打开的是一个任何分组表面都无法显示在该工作区下的会话。其他路径的工作区不受影响,因为那里不会积累未入账的空白会话;而 host-cwd 工作区每次 CLI 运行都会积累一个。 + +## 决定 + +复用扫描现在要求工作区成员关系:`blank` 且 `summary.cwd === workspace.path` 且 `workspace.sessionIds.includes(summary.id)` 且未归档。仅 cwd 匹配的情况落到 `session.create({ workspaceId })`,创建并挂接新会话,使工作区拥有它——这与流程中「不存在空白会话」时的既有分支完全相同。 + +## 曾考虑的替代方案 + +**收养游离会话而不是新建。** 让 `session.create({ workspaceId })` 挂接一个 cwd 匹配但未入账的空白会话。否决:静默地把 CLI 创建的会话挂到工作区上,越过了账户边界,令人意外;而且客户端没有成员视图就无法区分「游离会话」与「工作区自己的空白会话」——而成员视图本身就是本次修复。 + +**复用时就地挂接,新增一条 wire 操作。** 需要在导航热路径上新增 `workspace.attachSession` RPC,并且会话仍会有一帧显示在「未分组」;没有产品需求值得新增这个表面。 + +## 后果 + +游离空白会话仍显示在「未分组」(用户仍可手动打开),但不再被某个工作区的新建会话流程劫持。客户端镜像滞后(工作区帧落后于账户)时可能错过复用窗口而改为新建——这是既有的竞态行为,已按工作区合并。无 host、wire 或持久化格式变更。 + +## 测试 + +`packages/client/runtime/tests/workspaces-service.spec.ts` 覆盖四种结果:成员空白会话被复用(无 create RPC);cwd 匹配但非成员的游离空白会话**不被**复用、改为创建全新入账会话(回归用例);已归档空白会话不被复用;首次 prompt 被拒后成员空白会话仍可复用。完整客户端套件(`pnpm run test:gui`)保持绿色。 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index dd780369a1..adcc9595bb 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -20,7 +20,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and ## New Session and the blank mirror -`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path`) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure. +`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure. ## Pending queue projection diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index a0a76670f2..69910fd0c4 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -94,15 +94,19 @@ export class WorkspacesService implements IWorkspaces { // would miss the reuse scan and mint another hidden blank session. const inflight = this.connecting.get(workspaceId) if (inflight !== undefined) return inflight - // Reuse: blank && same canonical cwd (workspace.path is the host realpath - // canon; summary cwd is the session header passthrough of the same canon). - // An archived blank is never reused: reuse would open a session no - // grouping surface can show, so New Session mints a fresh one instead. + // Reuse requires workspace membership (id in sessionIds AND same + // canonical cwd — the host's own membership rule), never cwd alone: + // a cwd match can belong to no account (sessions the CLI/TUI birthed at + // the host cwd, or a deleted/recreated registration) and reusing it + // would open a session no grouping surface shows under this workspace. + // An archived blank is never reused either: reuse would open a session + // no grouping surface can show, so New Session mints a fresh one instead. const archived = this.list.getSnapshot().archivedSessionIds const sessions = this.sessions.list.getSnapshot() for (const id of sessions.ids) { const summary = sessions.byId[id] if (summary !== undefined && summary.blank && summary.cwd === workspace.path + && workspace.sessionIds.includes(summary.id) && !archived.includes(summary.id)) return summary.id } const attempt = this.sessions.create({ workspaceId }) diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 4323d7ffce..82357a1045 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -149,20 +149,26 @@ describe('WorkspacesService', () => { expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['stable-first', 'active']) }) - it('connectWorkspace reuses the workspace-matched blank session and creates otherwise', async () => { + it('connectWorkspace reuses the workspace-member blank session and creates otherwise', async () => { const ctx = new Context() const api = new FakeApiClient() const sessions = new SessionsService(ctx, api) const workspaces = new WorkspacesService(ctx, api, sessions) api.onWorkspaceList = () => Promise.resolve(ok({ - items: [workspace('alpha'), workspace('beta')] as never[], + items: [workspace('alpha', [sid('s-blank')]), workspace('beta'), workspace('gamma')] as never[], })) api.onList = () => Promise.resolve(ok({ items: [ - // Blank session already parked in alpha (cwd == workspace path canon). + // Blank session already parked in alpha (cwd == workspace path canon + // AND accounted under alpha): the reuse hit. { sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' }, // Non-blank sibling in beta must never be reused. { sessionId: sid('s-active'), updatedAt: 3, running: false, blank: false, cwd: '/w/beta' }, + // Stray blank at gamma's path but NOT accounted under gamma (a CLI + // session birthed at the host cwd): cwd alone must not hijack it — + // reuse would open a session gamma cannot show, so New Session mints + // a fresh accounted one instead. + { sessionId: sid('s-stray'), updatedAt: 4, running: false, blank: true, cwd: '/w/gamma' }, ] as never[], })) await Promise.all([workspaces.refresh(), sessions.refresh()]) @@ -181,6 +187,12 @@ describe('WorkspacesService', () => { // Same guarantee on the create arm (draft hand-off writes the machine pre-open). expect(sessions.binding(sid('s-fresh'))).toBeDefined() + // Miss: the stray blank matches gamma's path but is not a gamma member → + // never reused, a fresh accounted session is created instead. + api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh-3') })) + await expect(workspaces.connectWorkspace(wid('gamma'))).resolves.toBe('s-fresh-3') + expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }, { workspaceId: 'gamma' }]) + // Unknown workspace fails loud instead of silently creating in nowhere. await expect(workspaces.connectWorkspace(wid('ghost'))).rejects.toThrow(/unknown workspace ghost/) @@ -196,7 +208,7 @@ describe('WorkspacesService', () => { const api = new FakeApiClient() const sessions = new SessionsService(ctx, api) const workspaces = new WorkspacesService(ctx, api, sessions) - api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] })) + api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha', [sid('s-blank')])] as never[] })) api.onList = () => Promise.resolve(ok({ items: [{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' }] as never[], })) From 63ba836bce33e3917aadb02752ce9fe0c6ddc24a Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 5 Aug 2026 12:03:53 +0800 Subject: [PATCH 5/7] fix(client): address CI and review findings on blank-session reuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs: fix cross-class note links (../feature/...), mirror the membership reuse criterion in README.zh.md and the connectWorkspace architecture note (EN/ZH/sidecars re-recorded) - test(client): stray + member blank at the same path — the scan skips the stray and reuses the member - test(web): re-registration after delete now mints a fresh blank session (the old cwd-only reuse is gone), so the account is no longer empty; assert the retained seeded session is still not re-adopted --- ...session-scope-and-provide-channel.i18n.yaml | 4 ++-- ...client-session-scope-and-provide-channel.md | 6 +++--- ...ent-session-scope-and-provide-channel.zh.md | 6 +++--- ...ce-blank-session-reuse-membership.i18n.yaml | 4 ++-- ...workspace-blank-session-reuse-membership.md | 4 ++-- ...kspace-blank-session-reuse-membership.zh.md | 4 ++-- apps/web/tests/workspace-management.e2e.ts | 18 ++++++++++++------ packages/client/runtime/README.i18n.yaml | 4 ++-- packages/client/runtime/README.zh.md | 2 +- .../runtime/tests/workspaces-service.spec.ts | 11 ++++++++--- 10 files changed, 37 insertions(+), 26 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index cb7524565e..be4f2f6e7b 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: d19b256b834110d3cbb540cc0e039e61c693e98c -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 79f944b74976f82d5233a8d55916eff49aa7bf86 +2026-07-25-web-client-session-scope-and-provide-channel.md: 353cf35c9d6f5fa93a97fb0be60303ad6cef4d14 +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: d0860d1ae2ef868b4d81726fd5b167f3b236d6f7 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index d19b256b83..353cf35c9d 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -64,17 +64,17 @@ A session "materialized but with no first prompt" is governed by the summary-der - The host criterion: `session.events.length === 0` (zero log events = no user message yet). A live session reads `summarize()` straight from memory; a cold session is always `false` — the lazy-create contract guarantees a never-appended session never enters `persistence.list()` at all (both the JSONL and SQLite backends are verified truly lazy), so blank never touches disk. - The wire carries it in two places: the required `SessionSummary.blank` column, and the required `blank` field on the `host/session-added` frame (always true at creation, letting other tabs enter the same blank-session state into their mirrors). - The client mirror only lowers, never raises (monotonic), flipped from three sources, all reusing existing wire signals: - - The sender's own tab: the **successful response** to the first `prompt()` flips false (acceptance proves the user/message is already in the host log — this flip is confirmation, not optimism; `onEngaged` synchronously updates the list mirror, converting the current `New Session` row in place to an ordinary title, adding no list row). A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility. + - The sender's own tab: the **successful response** to the first `prompt()` flips false (acceptance proves the user/message is already in the host log — this flip is confirmation, not optimism; `onEngaged` synchronously updates the list mirror, converting the current `New Session` row in place to an ordinary title, adding no list row). A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility while it remains a Workspace member. - Other tabs: the `host/session-status (running:true)` frame flips it — a blank session never runs, so the first running necessarily means no longer blank; - Reconnect alignment: `session.list`'s summary.blank is authoritative, so a tab that missed frames aligns naturally on its next pull; a stale blank:true can never mark a converted session back to blank. - List discipline: the store retains every row; the Workspace browser's grouping, flat view, search, and counts share one visible projection — every non-blank session shows, while blank sessions show only the one with `session.id === sessions.current`, its title forced to `New Session`. After a Workspace switch, the old blank entity stays in the mirror but is hidden from the list while the target Workspace's current blank shows; the user-visible surface therefore holds at most one blank row globally. -- The residue ledger takes zero GC: after a refresh, blank sessions come back with the bit intact and are reused on the next same-workspace connect, so the ordinary single-tab path keeps at most one per workspace; after a host restart, blanks leave no disk trace and simply evaporate; the extra empty shells from multi-tab races only become non-current hidden rows, digested by later reuse, with no coordination. +- The residue ledger takes zero GC: after a refresh, blank sessions come back with the bit intact and are reused on the next same-workspace connect while they remain members, so the ordinary single-tab path keeps at most one per workspace; after a host restart, blanks leave no disk trace and simply evaporate; the extra empty shells from multi-tab races only become non-current hidden rows, digested by later reuse, with no coordination. ### connectWorkspace: the sole entry point of New Session `workspaces.connectWorkspace(workspaceId): Promise` (owned by WorkspacesService — it holds both the workspace canonical path and the sessions reference): -- The reuse arm: the list mirror is searched for `blank && cwd == workspace.path` (direct equality on the host realpath canonical form); a hit returns that id directly, creating nothing. +- The reuse arm: the list mirror is searched for `blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone. A cwd match without the account slot (a CLI/TUI session birthed at the host cwd, or a deleted/recreated registration) would open a session no grouping surface can show under this Workspace, so it falls through to the create arm instead (see the [membership reuse fix](../bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md)); a hit returns that id directly, creating nothing. - The create arm: on a miss, `session.create({workspaceId})` returns the new id. - An unknown workspaceId fails loud (never silently creating somewhere else). - The resolution guarantee (one contract for both arms): when the promise resolves, the returned id is already in the list store and `sessions.binding(id)` resolves synchronously — `SessionsService.create` projects the list synchronously after RPC success before resolving, so a draft mover can write text into the new scope's machine before open, without waiting for a notifier flush. diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index 79f944b749..b6d1a20073 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -64,17 +64,17 @@ Session 实例与 scope 同生命周期,存活资格 = host listed(一个判 - host 判据:`session.events.length === 0`(零日志事件 = 尚无用户消息)。live 会话 `summarize()` 内存直读;cold 会话恒 `false`——lazy-create 契约保证 never-appended 会话根本不进 `persistence.list()`(JSONL/SQLite 两后端均已实证真 lazy),blank 从不落盘。 - wire 承载两处:`SessionSummary.blank` 必填列;`host/session-added` 帧必填 `blank` 字段(创建时恒 true,供别的 tab 按同一空会话状态入镜像)。 - client 镜像只降不升(单调),三来源翻转,全部复用既有 wire 信号: - - 发送方本地:首次 `prompt()` 的**成功响应**翻 false(受理即证明 user/message 已入 host 日志——此点翻转是确证而非乐观;`onEngaged` 同步更新列表镜像,当前 `New Session` 行原地转为普通标题,不新增列表行)。首讯被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`、保持 connectWorkspace 复用资格。 + - 发送方本地:首次 `prompt()` 的**成功响应**翻 false(受理即证明 user/message 已入 host 日志——此点翻转是确证而非乐观;`onEngaged` 同步更新列表镜像,当前 `New Session` 行原地转为普通标题,不新增列表行)。首讯被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`、在仍为该工作区成员时保持 connectWorkspace 复用资格。 - 其他端:`host/session-status (running:true)` 帧翻转——blank 会话从不 running,首次 running 必然已非 blank; - 重连对齐:`session.list` 的 summary.blank 是权威,错过帧的端下次拉取自然对齐;陈旧的 blank:true 不能把已转正的会话重新标回 blank。 - 列表纪律:store 保留全部行;Workspace browser 的分组、平铺、搜索和计数共用同一可见投影——所有非 blank 会话都显示,blank 会话只显示 `session.id === sessions.current` 的一条,并强制标题为 `New Session`。切换 Workspace 后,旧 blank 实体仍在镜像中但从列表隐藏,目标 Workspace 的 current blank 显示;因此用户可见面全局至多一条 blank 行。 -- 残留账零 GC:刷新后 blank 会话带位回来,下次同 workspace 复用,普通单端路径使每个 workspace 至多保留一个;host 重启后 blank 无盘痕自然蒸发;多 tab 竞态多出的空壳只会成为非 current 隐藏行,后续复用消化,不做协调。 +- 残留账零 GC:刷新后 blank 会话带位回来,下次同 workspace 且仍为成员时复用,普通单端路径使每个 workspace 至多保留一个;host 重启后 blank 无盘痕自然蒸发;多 tab 竞态多出的空壳只会成为非 current 隐藏行,后续复用消化,不做协调。 ### connectWorkspace:New Session 的唯一入口 `workspaces.connectWorkspace(workspaceId): Promise`(归属 WorkspacesService——它同时持有 workspace 规范 path 与 sessions 引用): -- 复用臂:list mirror 中找 `blank && cwd == workspace.path`(host realpath 规范 canon 直等比较),命中直接返回该 id,不新建。 +- 复用臂:list mirror 中找 `blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd。没有账户槽位的 cwd 匹配(CLI/TUI 在 host cwd 创建的会话,或已删除/重建的注册)会打开一个任何分组表面都无法显示在该工作区下的会话,因此落到新建臂(见[成员复用修复](../bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md));命中直接返回该 id,不新建。 - 新建臂:未命中则 `session.create({workspaceId})`,返回新 id。 - 未知 workspaceId fail loud(不静默创建到别处)。 - 解析保证(两臂同契约):promise resolve 时返回的 id 已在 list store 且 `sessions.binding(id)` 同步可解析——`SessionsService.create` 在 RPC 成功后同步投影列表再 resolve,使 draft 搬运方可以在 open 之前往新 scope 的 machine 写文本,不等 notifier flush。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml index 7514000344..7737670466 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md -2026-08-05-workspace-blank-session-reuse-membership.md: 3f60c8d803bb09d6421e09886ec143296ed2b763 -2026-08-05-workspace-blank-session-reuse-membership.zh.md: 4932e85ef71a3cb2d5cad3cf9cf873460842e046 +2026-08-05-workspace-blank-session-reuse-membership.md: 910a10e9ada1a835df7a38a04fb04c504b0921df +2026-08-05-workspace-blank-session-reuse-membership.zh.md: 7e7aa899f73955b3d34a1eff3d9fedde097a17f0 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md index 3f60c8d803..910a10e9ad 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md @@ -8,7 +8,7 @@ English | [中文](2026-08-05-workspace-blank-session-reuse-membership.zh.md) Clicking the `+` on a Workspace group in the sidebar sometimes opened a session that the sidebar showed under Ungrouped instead of under the clicked Workspace — "entered a new session but the Workspace was not selected". The failure was specific to Workspaces registered at the directory the CLI runs from (in practice the harness checkout itself, i.e. `defaults.cwd = process.cwd()`), and appeared once a CLI-born blank session existed there. -Root cause: `connectWorkspace`'s blank-session reuse scanned the session list mirror on `cwd` equality alone. The host's own membership rule requires **both** an id in the Workspace account (`sessionIds`) **and** a session header whose canonical cwd equals the Workspace path ([Workspace UI product flow](2026-07-25-workspace-ui-product-flow.md)); a cwd match without the account slot is exactly the Ungrouped case. The reuse scan ignored the account slot, so any **live blank** session whose cwd matched qualified — including `main-session-*` sessions the CLI/TUI/headless entry points birth at the host cwd (`session.create({})` falls back to `defaults.cwd` and never attaches to a Workspace). When such a session was live and blank (no `turn/start` yet), the next `+` click on a Workspace registered at that path reused it and navigation opened a session no grouping surface can show under that Workspace. Workspaces at other paths were unaffected because no unaccounted blank sessions accumulate there; the host-cwd Workspace accumulated one per CLI run. +Root cause: `connectWorkspace`'s blank-session reuse scanned the session list mirror on `cwd` equality alone. The host's own membership rule requires **both** an id in the Workspace account (`sessionIds`) **and** a session header whose canonical cwd equals the Workspace path ([Workspace UI product flow](../feature/2026-07-25-workspace-ui-product-flow.md)); a cwd match without the account slot is exactly the Ungrouped case. The reuse scan ignored the account slot, so any **live blank** session whose cwd matched qualified — including `main-session-*` sessions the CLI/TUI/headless entry points birth at the host cwd (`session.create({})` falls back to `defaults.cwd` and never attaches to a Workspace). When such a session was live and blank (no `turn/start` yet), the next `+` click on a Workspace registered at that path reused it and navigation opened a session no grouping surface can show under that Workspace. Workspaces at other paths were unaffected because no unaccounted blank sessions accumulate there; the host-cwd Workspace accumulated one per CLI run. ## Decision @@ -22,7 +22,7 @@ The reuse scan now requires workspace membership: `blank` AND `summary.cwd === w ## Consequences -Stray blank sessions remain visible in Ungrouped (the user can still open them) but are never hijacked by a Workspace's New Session flow. A stale client mirror (Workspace frame lagging the account) can miss a reuse window and create instead — the pre-existing race behavior, already coalesced per Workspace. No host, wire, or durable-format change. +Stray blank sessions remain visible in Ungrouped (the user can still open them) but are never hijacked by a Workspace's New Session flow. Membership is a new condition on the reuse scan, and it has one observable stale-mirror edge: in the window where the session mirror is fresh but the Workspace account frame lags, the Workspace's own member blank can fail the membership check and a duplicate blank is minted where the old code reused — a second `New Session` row under that Workspace rather than the old failure shape (a session that no grouping surface shows). Both windows are transient and the per-Workspace coalescing still prevents duplicate creates racing one another. No host, wire, or durable-format change. ## Testing diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md index 4932e85ef7..7e7aa899f7 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md @@ -8,7 +8,7 @@ 在侧边栏某个工作区分组的 `+` 上创建会话时,有时会进入一个新会话,但侧边栏把它显示在「未分组」而不是点击的那个工作区下——「进入了新会话,但工作区没有被选中」。故障只出现在注册在 CLI 运行目录(即 `defaults.cwd = process.cwd()`,实际场景里就是 harness 检出目录本身)上的工作区,并且一旦该目录下存在 CLI 创建的空白会话就会出现。 -根因:`connectWorkspace` 的空白会话复用扫描只按 `cwd` 相等匹配会话列表镜像。host 自己的成员规则要求**同时**满足:会话 id 在工作区账户(`sessionIds`)中,**且**会话 header 的规范化 cwd 等于工作区路径([Workspace UI product flow](2026-07-25-workspace-ui-product-flow.md));只有 cwd 匹配而没有账户槽位的恰恰就是「未分组」的情形。复用扫描忽略了账户槽位,因此任何 cwd 匹配的**在线空白**会话都会被选中——包括 CLI/TUI/headless 入口在 host cwd 创建的 `main-session-*` 会话(`session.create({})` 回退到 `defaults.cwd`,从不挂到任何工作区)。当这样的会话在线且空白(尚无 `turn/start`)时,下一次在该路径注册的工作区上点击 `+` 就会复用它,导航打开的是一个任何分组表面都无法显示在该工作区下的会话。其他路径的工作区不受影响,因为那里不会积累未入账的空白会话;而 host-cwd 工作区每次 CLI 运行都会积累一个。 +根因:`connectWorkspace` 的空白会话复用扫描只按 `cwd` 相等匹配会话列表镜像。host 自己的成员规则要求**同时**满足:会话 id 在工作区账户(`sessionIds`)中,**且**会话 header 的规范化 cwd 等于工作区路径([Workspace UI product flow](../feature/2026-07-25-workspace-ui-product-flow.md));只有 cwd 匹配而没有账户槽位的恰恰就是「未分组」的情形。复用扫描忽略了账户槽位,因此任何 cwd 匹配的**在线空白**会话都会被选中——包括 CLI/TUI/headless 入口在 host cwd 创建的 `main-session-*` 会话(`session.create({})` 回退到 `defaults.cwd`,从不挂到任何工作区)。当这样的会话在线且空白(尚无 `turn/start`)时,下一次在该路径注册的工作区上点击 `+` 就会复用它,导航打开的是一个任何分组表面都无法显示在该工作区下的会话。其他路径的工作区不受影响,因为那里不会积累未入账的空白会话;而 host-cwd 工作区每次 CLI 运行都会积累一个。 ## 决定 @@ -22,7 +22,7 @@ ## 后果 -游离空白会话仍显示在「未分组」(用户仍可手动打开),但不再被某个工作区的新建会话流程劫持。客户端镜像滞后(工作区帧落后于账户)时可能错过复用窗口而改为新建——这是既有的竞态行为,已按工作区合并。无 host、wire 或持久化格式变更。 +游离空白会话仍显示在「未分组」(用户仍可手动打开),但不再被某个工作区的新建会话流程劫持。成员校验是复用扫描的新增条件,有一个可观察的镜像滞后边界:在会话镜像已新而工作区账户帧滞后的窗口里,工作区自己的成员空白会话可能因成员校验失败而错过复用,多创建一个空白——表现为该工作区下出现第二个「新会话」行,与旧故障形态(打开一个任何分组表面都无法显示的会话)不同。两个窗口都是瞬态的,按工作区的合并逻辑仍然防止并发创建互相竞争。无 host、wire 或持久化格式变更。 ## 测试 diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index b25320f488..a99336fd90 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -90,9 +90,9 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff { timeout: 10_000 }, ).not.toBeUndefined() // First adoption births a blank Session+Agent whose workspace attach must - // settle before a test may delete the registration; the reuse path (same - // canonical cwd already has a blank session) creates no agent, so callers - // opt in only where a fresh attach is possible. + // settle before a test may delete the registration; re-registration after + // a delete mints a fresh blank Session+Agent too (the old cwd-only reuse + // path is gone), so callers opt in only where a fresh attach is possible. if (options.waitForAgent === true) { await expect.poll(() => scaffold.ctx.agents.list().length, { timeout: 10_000 }) .toBeGreaterThan(agentsBefore) @@ -251,8 +251,10 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0) // Re-registering the exact deleted path immediately, without a reload, is - // a supported reversible flow. It creates a fresh Workspace id without - // re-adopting the retained Session. + // a supported reversible flow. It creates a fresh Workspace id and does + // NOT re-adopt the retained (non-blank) Session; the New Session flow + // mints a fresh blank session and attaches it to the new registration + // (the old cwd-only blank reuse is gone, so the account is never empty). await adoptDirectory(scaffold.workspaceCwd) await expect.poll( () => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd), @@ -261,7 +263,11 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff const reregistered = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd) expect(reregistered?.id).toBeDefined() expect(reregistered?.id).not.toBe(workspace.id) - expect(reregistered?.sessionIds).toEqual([]) + await expect.poll( + () => reregistered?.sessionIds ?? [], + { timeout: 10_000 }, + ).not.toEqual([]) + expect(reregistered?.sessionIds).not.toContain(SEED_ID) await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }) .toBeGreaterThanOrEqual(1) expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n') diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 7a785911c3..ece0786caf 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: dd780369a1d888dde2579e436afe2ce1e6dcfdd1 -README.zh.md: 5574ad6452c6a2d94fb63da7e53b98e8d074f1c9 +README.md: adcc9595bbf6865f1a01eedf9eead1da5d4b5657 +README.zh.md: 5857b929e5a2535420a46b6933ec0497fb1ff117 diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 5574ad6452..5857b929e5 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -20,7 +20,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## New Session 与 blank 镜像 -`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。 +`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd,避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。 ## 待处理队列投影 diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 82357a1045..832a1ff71a 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -159,8 +159,12 @@ describe('WorkspacesService', () => { })) api.onList = () => Promise.resolve(ok({ items: [ - // Blank session already parked in alpha (cwd == workspace path canon - // AND accounted under alpha): the reuse hit. + // Stray blank at alpha's path but NOT accounted under alpha (a CLI + // session birthed at the host cwd), sorted before the member blank: + // the scan must skip it and keep looking for a member hit. + { sessionId: sid('s-stray-alpha'), updatedAt: 1, running: false, blank: true, cwd: '/w/alpha' }, + // Blank session parked in alpha (cwd == workspace path canon AND + // accounted under alpha): the reuse hit. { sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' }, // Non-blank sibling in beta must never be reused. { sessionId: sid('s-active'), updatedAt: 3, running: false, blank: false, cwd: '/w/beta' }, @@ -174,7 +178,8 @@ describe('WorkspacesService', () => { await Promise.all([workspaces.refresh(), sessions.refresh()]) await Promise.resolve() - // Hit: same workspace → the parked blank session comes back, no create RPC. + // Hit: same workspace → the parked member blank comes back (the earlier + // cwd-matching non-member stray is skipped), no create RPC. await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank') expect(api.callsOf('session.create')).toEqual([]) // Resolution guarantee: the id is binding-resolvable synchronously. From abd2413976c414b05a8fd6e744e577c5a9d22019 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 5 Aug 2026 12:14:05 +0800 Subject: [PATCH 6/7] chore: retrigger CI (pull_request event was not delivered) From bb5fd7fcbd945d12024487d557bc479c66831fa7 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 5 Aug 2026 12:29:03 +0800 Subject: [PATCH 7/7] chore: re-record architecture note pairing after rebase merge --- ...07-25-web-client-session-scope-and-provide-channel.i18n.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index be4f2f6e7b..f9eb1c1988 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md 2026-07-25-web-client-session-scope-and-provide-channel.md: 353cf35c9d6f5fa93a97fb0be60303ad6cef4d14 -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: d0860d1ae2ef868b4d81726fd5b167f3b236d6f7 +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: b6d1a20073e1a43414d43b830ccc8ac2b1573bb2