From d907c8475fd1653c7b97596fd7a2753d9fa57957 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 4 Aug 2026 16:13:52 +0800 Subject: [PATCH 01/17] 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 02/17] 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 03/17] 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 e31b7221e726f6e93c6462a1e46dddd122e19816 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 16:33:35 +0800 Subject: [PATCH 04/17] feat(web): open the local settings file --- .../2026-07-30-web-config-plane.i18n.yaml | 4 +- .../2026-07-30-web-config-plane.md | 9 +- .../2026-07-30-web-config-plane.zh.md | 9 +- apps/web/tests/settings-chrome.e2e.ts | 24 ++++ .../settings-chrome/dialog.expected.md | 1 + docs/cordis-catalog/services.md | 8 ++ packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- .../client/connection/src/client/fixture.ts | 3 + packages/client/connection/src/index.ts | 1 + packages/client/connection/tests/fake-api.ts | 1 + .../client/connection/tests/node-half.spec.ts | 4 +- packages/client/runtime/tests/fake-api.ts | 1 + .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 4 +- .../client/ui-settings-general/README.zh.md | 4 +- .../client/SettingsDocumentAction.module.css | 16 +++ .../src/client/SettingsDocumentAction.tsx | 50 +++++++ .../ui-settings-general/src/client/index.ts | 32 ++++- .../ui-settings-general/src/client/locales.ts | 4 + .../src/client/settings-document-store.ts | 87 ++++++++++++ .../ui-settings-general/src/invariant.ts | 5 +- .../ui-settings-general/tests/apply.spec.ts | 29 +++- .../tests/components.spec.tsx | 82 ++++++++++- .../tests/settings-document-store.spec.ts | 132 ++++++++++++++++++ packages/client/ui-settings/README.i18n.yaml | 4 +- packages/client/ui-settings/README.md | 2 +- packages/client/ui-settings/README.zh.md | 2 +- .../src/client/SettingsRoot.module.css | 12 +- .../ui-settings/src/client/SettingsRoot.tsx | 1 + .../ui-settings/src/client/contract/slots.ts | 17 ++- .../client/ui-settings/src/client/index.ts | 1 + .../client/ui-settings/tests/apply.spec.ts | 7 +- .../ui-settings/tests/settings-root.spec.tsx | 8 ++ .../cordis/tool-cordis/src/api-catalog.ts | 4 + packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 110 ++++++++++++--- packages/host/apiproxy/src/api/rpc-map.ts | 1 + .../host/apiproxy/src/api/settings.schema.ts | 9 ++ packages/host/apiproxy/src/api/settings.ts | 23 ++- packages/host/apiproxy/src/fetch/client.ts | 6 +- packages/host/apiproxy/src/fetch/handler.ts | 9 +- .../host/apiproxy/src/native-path-opener.ts | 46 ++++-- .../apiproxy/tests/api-proxy-config.spec.ts | 59 +++++++- .../apiproxy/tests/client-handler.spec.ts | 11 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 3 + .../apiproxy/tests/native-path-opener.spec.ts | 14 +- .../settings/settings-local/README.i18n.yaml | 4 +- packages/settings/settings-local/README.md | 1 + packages/settings/settings-local/README.zh.md | 1 + packages/settings/settings-local/src/index.ts | 30 +++- .../settings-local/tests/local.spec.ts | 28 +++- .../settings-local/tests/lock-race.spec.ts | 53 +++++++ packages/settings/settings/README.i18n.yaml | 4 +- packages/settings/settings/README.md | 4 +- packages/settings/settings/README.zh.md | 4 +- packages/settings/settings/src/index.ts | 21 +++ .../settings/settings/tests/settings.spec.ts | 8 ++ 61 files changed, 939 insertions(+), 98 deletions(-) create mode 100644 packages/client/ui-settings-general/src/client/SettingsDocumentAction.module.css create mode 100644 packages/client/ui-settings-general/src/client/SettingsDocumentAction.tsx create mode 100644 packages/client/ui-settings-general/src/client/settings-document-store.ts create mode 100644 packages/client/ui-settings-general/tests/settings-document-store.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index fedfc7e489..c453ef0502 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.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-30-web-config-plane.md -2026-07-30-web-config-plane.md: 6d1a8c242c1888ee4fca9e21ebc814f7a345d633 -2026-07-30-web-config-plane.zh.md: c3255cacfdd1f06d12f7bb2631f95273536b7ef9 +2026-07-30-web-config-plane.md: 399684c782db86213ac7a0944e6e0bfe19145cea +2026-07-30-web-config-plane.zh.md: de4be026d7431e058e9ae0a6e48bb0a4aba769a7 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index 6d1a8c242c..399684c782 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-30-web-config-plane.zh.md) -> Scope: the wire face and web UI deferred from the [request-level LLM configuration note](2026-07-29-request-level-llm-config-credentials.md) — the `settings.*`/`credentials.*`/`llm.*` RPC domains with pushed invalidations, layered+redacted `describe()`, the llm configurable-provider directory and topology event, the standalone `dsh-client-schema-form` model layer, and the Models settings page with its hand-written provider editor. The `deepseek` → `deepseek-official` provider-route rename rides along as the enabling breaking change. +> Scope: the wire face and web UI deferred from the [request-level LLM configuration note](2026-07-29-request-level-llm-config-credentials.md) — the `settings.*`/`credentials.*`/`llm.*` RPC domains with pushed invalidations, layered+redacted `describe()`, the local settings-document handoff, the llm configurable-provider directory and topology event, the standalone `dsh-client-schema-form` model layer, and the Models settings page with its hand-written provider editor. The `deepseek` → `deepseek-official` provider-route rename rides along as the enabling breaking change. ## Problem @@ -12,10 +12,12 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer ## Decision -**Wire domains on the compiled RPC map, rejections as codes, invalidations as frames.** `settings.describe/update/replace`, `credentials.describe/set/unset`, `llm.providers`, and `llm.models` (claiming the reserved `host.listModels` surface) join `RpcMethodMap`, so the seven compiler-locked wiring sites keep contract, schema, handler, and client in lockstep. Seam rejections fold into `settings-rejected {ns}` / `credential-rejected {ref}` business errors (HTTP stays a carrier), and three `HostFrame`s — `host/settings-changed {ns}`, `host/credentials-changed {ref}`, `host/models-changed` — follow the `host/commands-changed` shape so every client converges without polling. Writes join `pickDirectory`/`openPath` in the connection guard's privileged set: loopback + same-origin or 403, because a LAN-exposed dsh web must not accept config mutation from another origin. +**Wire domains on the compiled RPC map, rejections as codes, invalidations as frames.** `settings.describe/openDocument/update/replace/mutate`, `credentials.describe/set/unset`, `llm.providers`, and `llm.models` (claiming the reserved `host.listModels` surface) join `RpcMethodMap`, so the seven compiler-locked wiring sites keep contract, schema, handler, and client in lockstep. Seam rejections fold into `settings-rejected {ns}` / `credential-rejected {ref}` business errors (HTTP stays a carrier), and three `HostFrame`s — `host/settings-changed {ns}`, `host/credentials-changed {ref}`, `host/models-changed` — follow the `host/commands-changed` shape so every client converges without polling. Settings reads, native actions, and writes join `pickDirectory`/`openPath` in the connection guard's privileged set: loopback + same-origin or 403, because a LAN-exposed dsh web must not accept configuration access from another origin. **`describe()` grows layers and structural secret redaction.** `SettingsDescriptor` carries `base`/`user` beside the effective value, so the form marks "overridden" by presence in the user layer, not value inequality (an override *equal* to the base is still an override). `describe({ redactSecrets: true })` — mandatory at every wire face — strips `role('secret')` subtrees from all three layers via a pure structural walk of the schema (object/dict/array containers; a secret-role subtree is one opaque leaf) and enumerates the stripped slots as `{path, set}`, so a page can render write-only inputs without ever receiving a value. +**The Host identifies and opens the local settings document.** The settings seam exposes optional `documentPath` provider metadata and a `prepareDocument()` operation; `settings-local` returns its fully resolved custom or `$DSH_HOME/settings.yaml` filename and exclusively creates an absent empty document with owner-only permissions, while non-file providers retain the base `undefined`. The loopback-only `settings.describe` response carries the path as availability metadata beside the redacted namespace views. `ui-settings-general` registers a `settings.action` entry only on loopback pages, shows it only after the metadata confirms a file, and invokes pathless `settings.openDocument`; the Host resolves the provider path again before a text-document handoff (`open -t` on macOS so an arbitrary YAML file association cannot redirect the gesture, `xdg-open` on Linux, and `Invoke-Item` on Windows). Generic workspace paths retain the existing default-application handoff. The browser neither derives `$DSH_HOME` nor receives a filesystem-target parameter; remote pages make no privileged settings read for this action. + **The llm seam declares configurability and announces topology.** `registerConfigurableProviders()` is an all-or-nothing, fiber-scoped directory of `{provider, displayName, settingsNs, settingsPath}` — the addressing a config page needs to open the right settings subtree for a route that may not exist yet; `listConfigurableProviders()` merges with live routes in the wire handler so undeclared live routes still report active. The zero-payload `'llm/adapters-updated'` event fires from all four registration/unregistration commit points with contained listener dispatch (INVARIANT rethrow), following the settings/commands precedent. `llm-deepseek`'s route renamed to `deepseek-official` because the pi-ai catalog legitimately owns `deepseek` as an aggregator entry; pre-release stance, no alias. **A hand-written editor over a schema model layer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The user chose the hand-written direction over adding a hint/grouping system, and a second round removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, plus `reasoningEffort` for deepseek / `reasoning` for pi-ai), with every other field owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, so a hand-coded field that drifts from its schema fails loud on save rather than silently. @@ -30,7 +32,8 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer - **Storing the typed key as a literal `apiKey` setting** — the v1 "one API key input" requirement could have written the literal into the profile, but every UI removal path rebuilds the user section from the *redacted* layers, so any reset or row deletion would silently drop stored sibling keys; deriving a reference keeps the input single-field while keeping `settings.yaml` secret-free and every replace safe. - **A `models` bridge plugin owning provider configuration** — same rejection as PR1: per-plugin namespaces plus a four-field directory declaration give the UI everything it needs; the bridge's unified dict re-imports the adapter-mapping indirection. - **Page-side polling instead of pushed frames** — the mux already carries `host/commands-changed`; three more frames cost one shape each and make a second tab, an external `settings.yaml` edit, and a settings-born route converge at event speed. +- **Hard-coding `$DSH_HOME/settings.yaml` or returning `documentPath` through `host.openPath` in the browser** — rejected because `settings-local.path` may select another YAML/JSON document, non-file providers have no Host path, and a general path request makes the browser the authority for a local filesystem target. Provider preparation is the authoritative source, and the Host-owned operation feeds the existing opener. ## Consequences -The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card, configured, and delete-confirmation states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The removal scenario proves cancellation leaves the profile intact, confirmation removes it, and the intentionally retained credential survives. The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and explicit removal of a provider's retained credential. +The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card, configured, and delete-confirmation states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The settings-shell scenario intercepts the pathless native intent; seam, provider, wire, React, and native-opener tests separately pin provider absence, custom-path resolution, absent-file materialization, owner-only permissions, hidden remote/unavailable states, duplicate-click collapse, localized failure, macOS text-editor dispatch, and Linux/Windows desktop dispatch. The removal scenario proves cancellation leaves the profile intact, confirmation removes it, and the intentionally retained credential survives. The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and explicit removal of a provider's retained credential. diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index c3255cacfd..de4be026d7 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-30-web-config-plane.md) | 中文 -> 范围:[请求级 LLM 配置 note](2026-07-29-request-level-llm-config-credentials.md) 中延后的 wire 面与 web UI——带推送式失效的 `settings.*`/`credentials.*`/`llm.*` RPC 领域、分层且脱敏的 `describe()`、llm 可配置提供方目录与拓扑事件、独立的 `dsh-client-schema-form` 模型层,以及带手写提供方编辑器的 Models 设置页。`deepseek` → `deepseek-official` 提供方路由重命名作为解锁前提的破坏性变更一并搭车合入。 +> 范围:[请求级 LLM 配置 note](2026-07-29-request-level-llm-config-credentials.md) 中延后的 wire 面与 web UI——带推送式失效的 `settings.*`/`credentials.*`/`llm.*` RPC 领域、分层且脱敏的 `describe()`、本地设置文档交接、llm 可配置提供方目录与拓扑事件、独立的 `dsh-client-schema-form` 模型层,以及带手写提供方编辑器的 Models 设置页。`deepseek` → `deepseek-official` 提供方路由重命名作为解锁前提的破坏性变更一并搭车合入。 ## 问题 @@ -12,10 +12,12 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 ## 决策 -**wire 领域挂上编译期 RPC 映射,拒绝落为错误码,失效落为帧。**`settings.describe/update/replace`、`credentials.describe/set/unset`、`llm.providers` 与 `llm.models`(认领预留的 `host.listModels` 面)一同加入 `RpcMethodMap`,七处由编译器锁定的接线位点因此让契约、schema、处理器与客户端保持步调一致。seam 侧的拒绝折叠为 `settings-rejected {ns}`/`credential-rejected {ref}` 业务错误(HTTP 仍只是载体),三个 `HostFrame`——`host/settings-changed {ns}`、`host/credentials-changed {ref}`、`host/models-changed`——沿用 `host/commands-changed` 的形状,因此每个客户端都无需轮询即可收敛。写入与 `pickDirectory`/`openPath` 一起进入连接守卫的特权集合:回环 + 同源,否则 403,因为暴露在局域网上的 dsh web 绝不能接受来自其他源的配置修改。 +**wire 领域挂上编译期 RPC 映射,拒绝落为错误码,失效落为帧。**`settings.describe/openDocument/update/replace/mutate`、`credentials.describe/set/unset`、`llm.providers` 与 `llm.models`(认领预留的 `host.listModels` 面)一同加入 `RpcMethodMap`,七处由编译器锁定的接线位点因此让契约、schema、处理器与客户端保持步调一致。seam 侧的拒绝折叠为 `settings-rejected {ns}`/`credential-rejected {ref}` 业务错误(HTTP 仍只是载体),三个 `HostFrame`——`host/settings-changed {ns}`、`host/credentials-changed {ref}`、`host/models-changed`——沿用 `host/commands-changed` 的形状,因此每个客户端都无需轮询即可收敛。settings 读取、原生操作与写入和 `pickDirectory`/`openPath` 一起进入连接守卫的特权集合:回环 + 同源,否则 403,因为暴露在局域网上的 dsh web 绝不能接受来自其他源的配置访问。 **`describe()` 增加分层与结构化 secret 脱敏。**`SettingsDescriptor` 在生效值之外携带 `base`/`user`,表单据此按「字段是否出现在用户层」来标记「已覆盖」,而非按值是否不等(与 base *相等*的覆盖仍然是覆盖)。`describe({ redactSecrets: true })`——在每个 wire 面都强制启用——经由对 schema 的纯结构遍历(object/dict/array 容器;secret 角色子树整体是一个不透明叶节点)从全部三层剥除 `role('secret')` 子树,并把剥除的槽位枚举为 `{path, set}`,页面因此不必收到任何值就能渲染只写输入框。 +**Host 识别并打开本地设置文档。** settings seam 暴露可选的 `documentPath` 提供方元数据和 `prepareDocument()` 操作;`settings-local` 返回已完全解析的自定义文件名或 `$DSH_HOME/settings.yaml` 文件名,并在文档缺失时以仅属主可访问的权限独占创建空文档,非文件提供方则保留基类的 `undefined`。仅限回环访问的 `settings.describe` 响应会在脱敏 namespace 视图旁把该路径作为可用性元数据携带。`ui-settings-general` 只在回环页面注册一条 `settings.action` 条目,只有元数据确认存在文件后才显示,并调用无路径参数的 `settings.openDocument`;Host 会在文本文档交接前再次解析提供方路径(macOS 上使用 `open -t`,使任意 YAML 文件关联无法重定向这次操作;Linux 上使用 `xdg-open`;Windows 上使用 `Invoke-Item`)。通用 Workspace 路径仍保留现有的默认应用交接。浏览器既不推导 `$DSH_HOME`,也不会收到文件系统目标参数;远程页面不会为这项操作发起特权 settings 读取。 + **llm seam 声明可配置性并公布拓扑。**`registerConfigurableProviders()` 是一个全有或全无、以 fiber 为作用域的目录,条目为 `{provider, displayName, settingsNs, settingsPath}`——这正是配置页要为一条可能尚不存在的路由打开正确设置子树时所需要的寻址;`listConfigurableProviders()` 在 wire 处理器里与存活路由合并,未声明的存活路由因此仍报告为激活。零负载的 `'llm/adapters-updated'` 事件从全部四个注册/注销提交点触发,listener 派发带异常隔离(INVARIANT 重抛),沿用 settings/commands 的先例。`llm-deepseek` 的路由重命名为 `deepseek-official`,因为 pi-ai catalog 名正言顺地拥有 `deepseek` 这个聚合器条目;依预发布立场,不设别名。 **架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,另加 deepseek 的 `reasoningEffort`/pi-ai 的 `reasoning`),其余每个字段都归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,因此偏离其 schema 的手写字段会在保存时大声失败,而非静默失败。 @@ -30,7 +32,8 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 - **把键入的密钥存成字面 `apiKey` 设置**——v1「单个 API 密钥输入框」的需求本可以把字面量直接写进 profile,但 UI 的每条删除路径都会从*脱敏后的*各层重建用户分节,任何重置或整行删除都会静默丢掉已存储的兄弟密钥;派生引用让输入保持单字段,同时让 `settings.yaml` 不含机密、每一次 replace 都安全。 - **由 `models` 桥接插件持有提供方配置**——与 PR1 相同的否决理由:按插件划分的 namespace 加上四字段的目录声明已经给了 UI 需要的一切;桥接层的统一字典会把适配器映射那层间接重新引进来。 - **页面侧轮询而非推送帧**——mux 已经承载 `host/commands-changed`;再加三个帧各自只多一个形状的成本,就让第二个标签页、外部的 `settings.yaml` 编辑和由设置催生的路由都以事件速度收敛。 +- **在浏览器中硬编码 `$DSH_HOME/settings.yaml`,或经 `host.openPath` 回传 `documentPath`**——否决,因为 `settings-local.path` 可能选择另一份 YAML/JSON 文档、非文件提供方没有 Host 路径,而且通用路径请求会让浏览器成为本地文件系统目标的权威。提供方的准备操作才是权威来源,由 Host 持有的操作会把结果交给现有打开器。 ## 后果 -整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态、已配置态与删除确认态各有 ARIA golden,另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。删除场景证明:取消后 profile 保持原样,确认后会将其删除,而刻意保留的凭据依然存在。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及显式删除提供方所保留的凭据。 +整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态、已配置态与删除确认态各有 ARIA golden,另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。设置外壳场景会截获无路径参数的原生意图;seam、提供方、wire、React 与原生打开器测试分别固定了提供方缺失、自定义路径解析、缺失文件创建、仅属主权限、远程/不可用时隐藏、重复点击合并、本地化失败、macOS 文本编辑器分发,以及 Linux/Windows 桌面分发。删除场景证明:取消后 profile 保持原样,确认后会将其删除,而刻意保留的凭据依然存在。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及显式删除提供方所保留的凭据。 diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 19e1b33538..43500585d8 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -61,6 +61,30 @@ describe('web e2e: settings modal and General preferences', () => { await dialog.getByRole('button', { name: 'Workspace Write' }).waitFor({ timeout: 10_000 }) await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1) await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1) + const openDocument = dialog.getByRole('button', { name: '打开配置文件' }) + await openDocument.waitFor({ timeout: 10_000 }) + let openRequests = 0 + await page.route('**/api/settings.openDocument', async (route) => { + const envelope = route.request().postDataJSON() as { + rpcId: string + payload: Record + } + expect(envelope.payload).toEqual({}) + openRequests += 1 + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + type: 'server-response', + rpcId: envelope.rpcId, + result: { ok: true, value: { opened: true } }, + }), + }) + }) + await openDocument.click() + await expect.poll(() => openRequests, { timeout: 5_000 }).toBe(1) + await expect.poll(() => openDocument.isEnabled(), { timeout: 5_000 }).toBe(true) + await page.unroute('**/api/settings.openDocument') // Golden of the freshly opened dialog (default zh, General active). const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(DIALOG_EXPECTED, snapshot, MODE) diff --git a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md index cb693b62a8..f358ff26f5 100644 --- a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md +++ b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md @@ -7,6 +7,7 @@ - button "模型": - img - text: 模型 + - button "打开配置文件" - button "关闭": - img - text: 关闭 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a713efa09c..40b6498a75 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1727,6 +1727,14 @@ Source: [`packages/session-title/session-title/src/index.ts:261`](../../packages Abstract settings service. Providers implement raw-document storage (`load`/`persist`) and push external changes through Settings.publish; the base class owns namespace registration, resolution, validation, change detection, and the `settings/updated` commit event. ```ts cordis-catalog +/** + * Prepare the provider's user-editable document for a native editor. File + * providers may materialize an absent document before returning its path; + * non-file providers return undefined. + * @returns the absolute local document path, or undefined for non-file storage. + */ +prepareDocument(): Promise + /** * Register a namespace schema and receive its owner scope. The registration * is an effect on the calling plugin's fiber: disposing that fiber removes diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index a636d8bc49..bb48dfea60 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/connection/README.md -README.md: f537fee3273e3b5d2411197cf1a1a6e0d34af5f9 -README.zh.md: a29d2c00e7df3f6290a03ffdad59b70b43702aca +README.md: 33ef3431a2a0a6c8c0f68e040da190741fb482a6 +README.zh.md: 2b6280dd957a809d61d12601cdca5af2eccbcdab diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index f537fee327..33ef3431a2 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Loopback hostname classification stays package-internal: the `/api` Host fence uses it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Loopback hostname classification stays package-internal: the `/api` Host fence uses it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. ## /api browser-trust fence diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index a29d2c00e7..2b6280dd95 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 ## /api 浏览器信任栅栏 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index dff0069b87..4816ab70a5 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2390,6 +2390,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { // editor; real schema-driven forms ride the HTTP transport. describe: request => ok(request, { writable: true, + documentPath: `${FIXTURE_HOME}/settings.yaml`, namespaces: [{ ns: 'llm-deepseek', schema: {}, @@ -2399,6 +2400,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { revision: 0, }], }), + openDocument: request => ok(request, { opened: true as const }), update: request => err(request, { code: 'settings-rejected', message: 'fixture: the minimal readiness settings descriptor is read-only', @@ -2549,6 +2551,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'goal.complete': return this.api.goals.complete(request) case 'goal.clear': return this.api.goals.clear(request) case 'settings.describe': return this.api.settings.describe(request) + case 'settings.openDocument': return this.api.settings.openDocument(request, signal) case 'settings.update': return this.api.settings.update(request) case 'settings.replace': return this.api.settings.replace(request) case 'settings.mutate': return this.api.settings.mutate(request) diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index ed4af2d21f..5780518281 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -52,6 +52,7 @@ const PRIVILEGED_METHODS = new Set([ 'host.pickDirectory', 'host.openPath', 'settings.describe', + 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 88708f0625..c70a8a7ad0 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -182,6 +182,7 @@ export class FakeApiClient implements IApiClient { readonly settings: IApiClient['settings'] = { describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))), + openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 08c65de2ba..de7d613f8e 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -93,7 +93,7 @@ describe('connection node half', () => { // passed), but each privileged method stays loopback-only and 403s. for (const method of [ 'host.pickDirectory', 'host.openPath', - 'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate', + 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', 'credentials.describe', 'credentials.set', 'credentials.unset', ]) { const denied = fakeResponse() @@ -177,7 +177,7 @@ describe('connection node half over a real HTTP server', () => { // Reads are as privileged as writes: describe returns the exposed // configuration, and credentials.describe probes arbitrary env-var names. for (const method of [ - 'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate', + 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', 'credentials.describe', 'credentials.set', 'credentials.unset', 'host.pickDirectory', 'host.openPath', ]) { diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 3c0b86fec8..d2b5c13a5d 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -217,6 +217,7 @@ export class FakeApiClient implements IApiClient { readonly settings: IApiClient['settings'] = { describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))), + openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index b14bd3ded3..a4b757d7fe 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/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-settings-general/README.md -README.md: 0202d596f509feeba39a38254e8bab2fae27b649 -README.zh.md: adec73edda00d34e209772f0bcc54a994f593997 +README.md: d9aeaa37b3953f586821871118b009abd72b896d +README.zh.md: 96666e2d4d2087ca9579a1bcdd6a00ccfdaaa90d diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 0202d596f5..d9aeaa37b3 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -2,7 +2,9 @@ English | [中文](README.zh.md) -Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages. +Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the local configuration-file action, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages. + +A loopback browser loads the provider's optional `documentPath` through `settings.describe` and renders **Open configuration file** only when the Host confirms one local file. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows). Open failures keep the action available and render a localized error. Remote browsers never register the action and never issue the privileged settings read. `src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out. diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index adec73edda..96666e2d4d 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -2,7 +2,9 @@ [English](README.md) | 中文 -设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。 +设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容、本地配置文件操作,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。 + +回环浏览器通过 `settings.describe` 加载提供方可选的 `documentPath`,且只有在 Host 确认存在一个本地文件时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。远程浏览器从不注册该操作,也从不发起这项特权 settings 读取。 `src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在 user-settings seam 中注册 `ui-onboarding`。loopback 浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。非 loopback 浏览器不能访问受保护的 settings API:它仍会显示通知,但「继续」只推进当前浏览器进程,重新加载后会再次显示通知。版本不同时,系统也会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。 diff --git a/packages/client/ui-settings-general/src/client/SettingsDocumentAction.module.css b/packages/client/ui-settings-general/src/client/SettingsDocumentAction.module.css new file mode 100644 index 0000000000..c4bb82e12f --- /dev/null +++ b/packages/client/ui-settings-general/src/client/SettingsDocumentAction.module.css @@ -0,0 +1,16 @@ +.action { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; +} + +.error { + max-width: 180px; + overflow: hidden; + color: var(--dsw-alias-state-error-primary); + font-size: 12px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/packages/client/ui-settings-general/src/client/SettingsDocumentAction.tsx b/packages/client/ui-settings-general/src/client/SettingsDocumentAction.tsx new file mode 100644 index 0000000000..6904ae7183 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/SettingsDocumentAction.tsx @@ -0,0 +1,50 @@ +/** Optional settings-header action for opening a file-backed Host document. */ + +import { useEffect } from 'react' +import type { ReactNode } from 'react' +import { Button } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' +import type { SettingsDocumentState, SettingsDocumentStore } from './settings-document-store.ts' +import css from './SettingsDocumentAction.module.css' + +/** Registrant-owned dependencies of {@link SettingsDocumentAction}. */ +export interface SettingsDocumentActionInjected { + /** Provider metadata and action state owner. */ + controller: SettingsDocumentStore + /** Bound selector hook for the controller snapshot. */ + useSnapshot: SnapshotSelectorHook +} + +/** Header-action owner share, localized copy, and the registrant's state face. */ +export type SettingsDocumentActionProps = + PropsRuntime<'settings.action'> & PropsLocale<'settings'> & SettingsDocumentActionInjected + +/** + * Render the open-document action only after Host metadata confirms a local file. + * @param props - header owner props, localized copy, and injected document state. + * @returns the action, or null while unavailable or unresolved. + */ +export function SettingsDocumentAction({ controller, useSnapshot, t }: SettingsDocumentActionProps): ReactNode { + const state = useSnapshot(snapshot => snapshot) + + useEffect(() => { + if (state.status === 'idle') void controller.load() + }, [controller, state.status]) + + if (state.status !== 'ready') return null + + return ( +
+ {state.error === null ? null : {t('openDocument.error')}} + +
+ ) +} diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 87b05e86cb..bdcb2876e6 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -1,8 +1,8 @@ /** * Settings ownerless-copy plugin, browser half: registers everything on the * Settings surface that belongs to no single feature — the trigger/header - * chrome content, the General section, and the `settings` dictionaries. - * Feature-owned rows and sections stay with their features. + * chrome content, local-document action, General section, and `settings` + * dictionaries. Feature-owned rows and sections stay with their features. * Export discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' @@ -15,6 +15,9 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx' import { GeneralSection } from './GeneralSection.tsx' +import { SettingsDocumentAction } from './SettingsDocumentAction.tsx' +import type { SettingsDocumentActionInjected } from './SettingsDocumentAction.tsx' +import { SettingsDocumentStore } from './settings-document-store.ts' import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx' import { WelcomeNotice } from './WelcomeNotice.tsx' import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from './welcome-store.ts' @@ -27,6 +30,9 @@ export type { export type { GeneralSectionComponentProps, } from './GeneralSection.tsx' +export type { SettingsDocumentActionInjected, SettingsDocumentActionProps } from './SettingsDocumentAction.tsx' +export type { SettingsDocumentState } from './settings-document-store.ts' +export { SettingsDocumentStore } from './settings-document-store.ts' export type { WelcomeNoticeInjected, WelcomeNoticeProps } from './WelcomeNotice.tsx' export type { WelcomeNoticeState } from './welcome-store.ts' export type { SettingsKey } from './locales.ts' @@ -61,6 +67,15 @@ export function apply(ctx: ClientContext): void { // locale/change re-registration wiring. const t = ctx.locale.bind(NS) const connection = ctx.get('connection') as ConnectionHandle + const documentController = connection.isLoopback + ? new SettingsDocumentStore(connection.api) + : undefined + const documentInjected = documentController === undefined + ? undefined + : (() => { + const useSnapshot = bindSnapshotSelector(documentController.store) + return (): SettingsDocumentActionInjected => ({ controller: documentController, useSnapshot }) + })() const welcomeController = new WelcomeNoticeStore(connection.api, connection.isLoopback ? 'host' : 'memory') const useWelcomeSnapshot = bindSnapshotSelector(welcomeController.store) const welcomeInjected = (): WelcomeNoticeInjected => ({ @@ -84,6 +99,16 @@ export function apply(ctx: ClientContext): void { ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent)) const header = deferRegistration(ctx.slots, 'settings.header', HeaderContent, () => ctx.slots.register({ name: 'settings.header', locale: NS }, HeaderContent)) + const action = documentInjected === undefined + ? undefined + : deferRegistration(ctx.slots, 'settings.action', SettingsDocumentAction, () => + ctx.slots.register({ + name: 'settings.action', + id: 'open-document', + order: 0, + locale: NS, + inject: documentInjected, + }, SettingsDocumentAction)) const close = deferRegistration(ctx.slots, 'settings.close', CloseLabel, () => ctx.slots.register({ name: 'settings.close', locale: NS }, CloseLabel)) const general = deferRegistration(ctx.slots, 'settings.section', GeneralSection, () => @@ -106,9 +131,10 @@ export function apply(ctx: ClientContext): void { return () => { trigger.dispose() header.dispose() + action?.dispose() close.dispose() general.dispose() welcome.dispose() } - }, 'ui-settings-general: chrome, section, and onboarding registrations') + }, 'ui-settings-general: chrome, action, section, and onboarding registrations') } diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index ef300e8e1f..41a44eae3f 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -6,6 +6,8 @@ export const zh = { 'trigger': '设置', 'title': '设置', 'close': '关闭', + 'openDocument': '打开配置文件', + 'openDocument.error': '无法打开配置文件', 'general.nav': '通用设置', 'welcome.title': WELCOME_NOTICE_COPY.zh.title, 'welcome.paragraph.0': WELCOME_NOTICE_COPY.zh.paragraphs[0], @@ -24,6 +26,8 @@ export const en = { 'trigger': 'Settings', 'title': 'Settings', 'close': 'Close', + 'openDocument': 'Open configuration file', + 'openDocument.error': 'Could not open configuration file', 'general.nav': 'General', 'welcome.title': WELCOME_NOTICE_COPY.en.title, 'welcome.paragraph.0': WELCOME_NOTICE_COPY.en.paragraphs[0], diff --git a/packages/client/ui-settings-general/src/client/settings-document-store.ts b/packages/client/ui-settings-general/src/client/settings-document-store.ts new file mode 100644 index 0000000000..ff55517771 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/settings-document-store.ts @@ -0,0 +1,87 @@ +/** State owner for the optional local settings-document action. */ + +import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' + +/** Browser state of the Host-owned settings document. */ +export interface SettingsDocumentState { + /** Metadata-loading phase; unavailable means the provider has no local file or the read failed. */ + status: 'idle' | 'loading' | 'ready' | 'unavailable' + /** Whether one native-open request is in flight. */ + opening: boolean + /** Last metadata/native-open diagnostic; UI exposes only localized copy. */ + error: string | null +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +/** Loads local-document availability and invokes the pathless Host-owned open operation. */ +export class SettingsDocumentStore { + /** uSES-safe state source shared by the registered header action. */ + readonly store: SnapshotStore = createSnapshotStore({ + status: 'idle', opening: false, error: null, + }) + + private generation = 0 + + /** + * @param api - loopback settings wire face that reports and opens the provider document. + */ + constructor(private readonly api: Pick) {} + + /** + * Load the current provider's optional local document path. + * @returns after the latest metadata response updates the store. + */ + async load(): Promise { + const generation = ++this.generation + this.store.update((state) => { + state.status = 'loading' + state.error = null + }) + try { + const { result } = await this.api.settings.describe({}) + if (generation !== this.generation) return + if (!result.ok) { + this.store.update((state) => { + state.status = 'unavailable' + state.error = result.error.message + }) + return + } + this.store.update((state) => { + state.status = result.value.documentPath === undefined ? 'unavailable' : 'ready' + state.error = null + }) + } catch (error) { + if (generation !== this.generation) return + this.store.update((state) => { + state.status = 'unavailable' + state.error = messageOf(error) + }) + } + } + + /** + * Open the loaded document once; concurrent gestures collapse behind the in-flight action. + * @returns after the native-open request settles, or immediately when unavailable/already opening. + */ + async open(): Promise { + const current = this.store.getSnapshot() + if (current.status !== 'ready' || current.opening) return + this.store.update((state) => { + state.opening = true + state.error = null + }) + try { + const response = await this.api.settings.openDocument({}) + if (!response.result.ok) throw new Error(response.result.error.message) + } catch (error) { + this.store.update((state) => { state.error = messageOf(error) }) + } finally { + this.store.update((state) => { state.opening = false }) + } + } +} diff --git a/packages/client/ui-settings-general/src/invariant.ts b/packages/client/ui-settings-general/src/invariant.ts index d13ecc5cb8..c5917bd83b 100644 --- a/packages/client/ui-settings-general/src/invariant.ts +++ b/packages/client/ui-settings-general/src/invariant.ts @@ -16,8 +16,9 @@ export const inject = ['invariants'] /** * No runtime invariant: the settings seam validates and publishes the durable - * welcome section, while slot conflicts fail loud in the slot core; this - * package owns no additional event/data relationship between those systems. + * welcome section, while slot conflicts fail loud in the slot core. The local + * document action is browser state over typed RPC responses and is covered by + * store/component tests rather than a Cordis runtime relationship. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts index 73f6d8207e..0f79b0c4a0 100644 --- a/packages/client/ui-settings-general/tests/apply.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -1,4 +1,4 @@ -/** Ownerless-copy registrations: the four seats, the dictionaries, thunked labels, and HMR recovery. */ +/** Ownerless-copy registrations: the six seats, dictionaries, thunked labels, and HMR recovery. */ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' @@ -8,6 +8,8 @@ import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' import { GeneralSection } from '../src/client/GeneralSection.tsx' +import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx' +import type { SettingsDocumentActionInjected } from '../src/client/SettingsDocumentAction.tsx' import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx' import type { WelcomeNoticeInjected } from '../src/client/WelcomeNotice.tsx' import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts' @@ -16,10 +18,11 @@ import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts' // the shipped Chinese copy, so they state the browser they assume. usePinnedBrowserLanguages('zh-CN') -/** The five seats this plugin fills (slot name → expected component). */ +/** The seats this plugin fills for a loopback browser (slot name → expected component). */ const SEATS = [ ['settings.trigger', TriggerContent], ['settings.header', HeaderContent], + ['settings.action', SettingsDocumentAction], ['settings.close', CloseLabel], ['settings.section', GeneralSection], ['settings.onboarding', WelcomeNotice], @@ -36,6 +39,7 @@ async function bench(isLoopback = true) { ok: true as const, value: { writable: true, + documentPath: '/tmp/test-settings.yaml', namespaces: [{ ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, schema: {}, @@ -47,11 +51,18 @@ async function bench(isLoopback = true) { }, }, })) - ctx.provide('connection', { api: { settings: { describe: settingsDescribe } }, isLoopback } as never) - return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe } + const settingsOpenDocument = vi.fn(() => Promise.resolve({ + rpcId: 'settings-open' as never, + result: { ok: true as const, value: { opened: true as const } }, + })) + ctx.provide('connection', { + api: { settings: { describe: settingsDescribe, openDocument: settingsOpenDocument } }, + isLoopback, + } as never) + return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe, settingsOpenDocument } } -/** Declare the shell's four child slots the way ui-settings' entry does. */ +/** Declare the shell's six child slots the way ui-settings' entry does. */ function declare(slots: SlotsService): () => void { return slots.register( { @@ -59,6 +70,7 @@ function declare(slots: SlotsService): () => void { children: { 'settings.trigger': { kind: 'single', scope: 'root' }, 'settings.header': { kind: 'single', scope: 'root' }, + 'settings.action': { kind: 'list', scope: 'root' }, 'settings.close': { kind: 'single', scope: 'root' }, 'settings.section': { kind: 'list', scope: 'root' }, 'settings.onboarding': { kind: 'list', scope: 'root' }, @@ -77,7 +89,7 @@ describe('ui-settings-general apply', () => { expect(inject).toEqual(['slots', 'locale', 'connection']) }) - it('fills all five seats for declarations before or after apply', async () => { + it('fills all six seats for declarations before or after apply', async () => { const before = await bench() declare(before.slots) await before.ctx.plugin({ inject: [...inject], apply }).await() @@ -92,6 +104,10 @@ describe('ui-settings-general apply', () => { expect(before.slots.entries('settings.general.item')).toEqual([]) const welcome = before.slots.entries('settings.onboarding')[0]! expect(welcome.options).toMatchObject({ id: 'welcome-notice', order: -100 }) + const action = before.slots.entries('settings.action')[0]! + const actionInjected = (action.inject as unknown as () => SettingsDocumentActionInjected)() + expect(actionInjected.controller.store.getSnapshot().status).toBe('idle') + expect(actionInjected.useSnapshot).toEqual(expect.any(Function)) // Copy rides the standard locale seat: every seat declares the namespace. for (const [name] of SEATS) { expect(before.slots.entries(name)[0]!.locale).toBe('settings') @@ -170,6 +186,7 @@ describe('ui-settings-general apply', () => { await expect(controller.acknowledge()).resolves.toBe(true) expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true }) expect(b.settingsDescribe).not.toHaveBeenCalled() + expect(b.slots.entries('settings.action')).toEqual([]) }) it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => { diff --git a/packages/client/ui-settings-general/tests/components.spec.tsx b/packages/client/ui-settings-general/tests/components.spec.tsx index db6be78ccd..0b11a575f4 100644 --- a/packages/client/ui-settings-general/tests/components.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.spec.tsx @@ -1,10 +1,13 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { GeneralSectionComponentProps } from '../src/client/GeneralSection.tsx' import { GeneralSection } from '../src/client/GeneralSection.tsx' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' import type { TriggerContentProps } from '../src/client/chrome.tsx' +import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx' +import { SettingsDocumentStore } from '../src/client/settings-document-store.ts' import { en } from '../src/client/locales.ts' afterEach(cleanup) @@ -54,3 +57,80 @@ describe('GeneralSection', () => { expect(screen.getByTestId('slot-settings.general.item')).toBeTruthy() }) }) + +describe('SettingsDocumentAction', () => { + it('appears only for a file-backed provider and requests its Host-owned document', async () => { + const openDocument = vi.fn(() => Promise.resolve({ + rpcId: 'document-open' as never, + result: { ok: true as const, value: { opened: true as const } }, + })) + const controller = new SettingsDocumentStore({ + settings: { + describe: vi.fn(() => Promise.resolve({ + rpcId: 'document-action' as never, + result: { + ok: true as const, + value: { writable: true, documentPath: '/tmp/custom.yaml', namespaces: [] }, + }, + })), + openDocument, + }, + } as never) + render() + const action = await screen.findByRole('button', { name: 'Open configuration file' }) + fireEvent.click(action) + await waitFor(() => { expect(openDocument).toHaveBeenCalledWith({}) }) + }) + + it('stays absent when the provider has no local document', async () => { + const controller = new SettingsDocumentStore({ + settings: { + describe: vi.fn(() => Promise.resolve({ + rpcId: 'document-action' as never, + result: { ok: true as const, value: { writable: true, namespaces: [] } }, + })), + openDocument: vi.fn(), + }, + } as never) + render() + await waitFor(() => { expect(controller.store.getSnapshot().status).toBe('unavailable') }) + expect(screen.queryByRole('button', { name: 'Open configuration file' })).toBeNull() + }) + + it('keeps the action available and reports a native-open failure', async () => { + const controller = new SettingsDocumentStore({ + settings: { + describe: vi.fn(() => Promise.resolve({ + rpcId: 'document-action' as never, + result: { + ok: true as const, + value: { writable: true, documentPath: '/tmp/settings.yaml', namespaces: [] }, + }, + })), + openDocument: vi.fn(() => Promise.resolve({ + rpcId: 'document-open-failed' as never, + result: { ok: false as const, error: { code: 'internal' as const, message: 'xdg-open missing', details: {} } }, + })), + }, + } as never) + render() + fireEvent.click(await screen.findByRole('button', { name: 'Open configuration file' })) + expect((await screen.findByRole('alert')).textContent).toBe('Could not open configuration file') + expect(screen.getByRole('button', { name: 'Open configuration file' })).toBeTruthy() + }) +}) diff --git a/packages/client/ui-settings-general/tests/settings-document-store.spec.ts b/packages/client/ui-settings-general/tests/settings-document-store.spec.ts new file mode 100644 index 0000000000..2104e3e1f1 --- /dev/null +++ b/packages/client/ui-settings-general/tests/settings-document-store.spec.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client' +import { SettingsDocumentStore } from '../src/client/settings-document-store.ts' + +function response(documentPath?: string): RpcResponse<{ + writable: boolean + documentPath?: string + namespaces: [] +}> { + return { + rpcId: 'settings-document' as never, + result: { + ok: true, + value: { writable: true, ...documentPath === undefined ? {} : { documentPath }, namespaces: [] }, + }, + } +} + +function opened(): RpcResponse<{ opened: true }> { + return { + rpcId: 'settings-open' as never, + result: { ok: true, value: { opened: true } }, + } +} + +function describeFailed(message: string): RpcResponse { + return { + rpcId: 'settings-document-failed' as never, + result: { ok: false, error: { code: 'internal', message, details: {} } }, + } +} + +describe('SettingsDocumentStore', () => { + it('loads provider metadata and asks the settings domain to open its document', async () => { + const describe = vi.fn(() => Promise.resolve(response('/home/test/settings.yaml'))) + const openDocument = vi.fn(() => Promise.resolve(opened())) + const controller = new SettingsDocumentStore({ settings: { describe, openDocument } } as never) + await controller.load() + expect(controller.store.getSnapshot()).toEqual({ + status: 'ready', opening: false, error: null, + }) + await controller.open() + expect(openDocument).toHaveBeenCalledWith({}) + }) + + it('marks absent or failed metadata unavailable without opening anything', async () => { + const openDocument = vi.fn(() => Promise.resolve(opened())) + const absent = new SettingsDocumentStore({ + settings: { describe: () => Promise.resolve(response()), openDocument }, + } as never) + await absent.load() + await absent.open() + expect(absent.store.getSnapshot().status).toBe('unavailable') + expect(openDocument).not.toHaveBeenCalled() + + const failed = new SettingsDocumentStore({ + settings: { describe: () => Promise.reject(new Error('offline')), openDocument }, + } as never) + await failed.load() + expect(failed.store.getSnapshot()).toMatchObject({ status: 'unavailable', error: 'offline' }) + + const rejected = new SettingsDocumentStore({ + settings: { describe: () => Promise.resolve(describeFailed('provider failed')), openDocument }, + } as never) + await rejected.load() + expect(rejected.store.getSnapshot()).toMatchObject({ + status: 'unavailable', error: 'provider failed', + }) + }) + + it('collapses concurrent open gestures and recovers after a failure', async () => { + let resolveOpen!: (response: RpcResponse<{ opened: true }>) => void + const openDocument = vi.fn(() => new Promise>((resolve) => { resolveOpen = resolve })) + const controller = new SettingsDocumentStore({ + settings: { describe: () => Promise.resolve(response('/tmp/settings.yaml')), openDocument }, + } as never) + await controller.load() + const first = controller.open() + const second = controller.open() + expect(openDocument).toHaveBeenCalledOnce() + resolveOpen({ + rpcId: 'settings-open-failed' as never, + result: { ok: false, error: { code: 'internal', message: 'no default editor', details: {} } }, + }) + await Promise.all([first, second]) + expect(controller.store.getSnapshot()).toMatchObject({ + status: 'ready', opening: false, error: 'no default editor', + }) + }) + + it('ignores stale metadata completions and reports non-Error native failures', async () => { + let resolveFirst!: (value: ReturnType) => void + const first = new Promise>((resolve) => { resolveFirst = resolve }) + const describe = vi.fn() + .mockReturnValueOnce(first) + .mockResolvedValueOnce(response('/tmp/current.yaml')) + let rejectOpen!: (reason?: unknown) => void + const controller = new SettingsDocumentStore({ + settings: { + describe, + openDocument: () => new Promise((_, reject) => { rejectOpen = reject }), + }, + } as never) + const stale = controller.load() + await controller.load() + resolveFirst(response()) + await stale + expect(controller.store.getSnapshot().status).toBe('ready') + const opening = controller.open() + rejectOpen('native unavailable') + await opening + expect(controller.store.getSnapshot()).toMatchObject({ + status: 'ready', opening: false, error: 'native unavailable', + }) + + let rejectFirst!: (error: Error) => void + const rejectedFirst = new Promise>((_, reject) => { rejectFirst = reject }) + const caught = new SettingsDocumentStore({ + settings: { + describe: vi.fn() + .mockReturnValueOnce(rejectedFirst) + .mockResolvedValueOnce(response('/tmp/current.yaml')), + openDocument: vi.fn(), + }, + } as never) + const staleRejection = caught.load() + await caught.load() + rejectFirst(new Error('stale offline')) + await staleRejection + expect(caught.store.getSnapshot()).toMatchObject({ status: 'ready', error: null }) + }) +}) diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index 668853e41f..6c4fee22aa 100644 --- a/packages/client/ui-settings/README.i18n.yaml +++ b/packages/client/ui-settings/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-settings/README.md -README.md: 14c78c83467313a6efa7033c31fb9c9b1cd94e0c -README.zh.md: 8831842d03db4572f1dca5547b84c0d8a3be8f2d +README.md: de78d599b7833179339ceeb680fbd665b056bd83 +README.zh.md: fdc6ade1413c7d02afba85ed3b69a2b78b401836 diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index 14c78c8346..de78d599b7 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.section` (one page per feature), and `settings.onboarding` (ordered feature-owned pages in a full-viewport stage). The shell ships no copy of its own — all text arrives from registrants (ui-settings-general owns chrome, General, and the product notice; features own their sections, rows, and conditional onboarding pages). Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency). +Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.action` (ordered content-header actions), `settings.section` (one page per feature), and `settings.onboarding` (ordered feature-owned pages in a full-viewport stage). The shell ships no copy of its own — all text arrives from registrants (ui-settings-general owns chrome, General, and the product notice; features own their actions, sections, rows, and conditional onboarding pages). Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency). The shell projects the onboarding ledger into ascending order and mounts exactly one page at a time in a body-level stage while marking the underlying app root inert. The active registrant receives its id, `complete()`, and an `openSection(id)` callback; completing or skipping transfers ownership to the next entry. Registrants own durable completion, capability readiness, copy, and mutations, so independently registered flows cannot stack and the shell does not become a second configuration fact source. diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index 8831842d03..fdc6ade141 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/packages/client/ui-settings/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、显示在全视口展示层中的有序页面)。外壳不自带文案:所有文本都来自注册方(ui-settings-general 拥有界面框架、「通用」分区和产品声明;各功能拥有各自的分区、行和条件式首次使用引导页面)。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。 +设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.action`(内容标题栏中的有序操作)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、显示在全视口展示层中的有序页面)。外壳不自带文案:所有文本都来自注册方(ui-settings-general 拥有界面框架、「通用」分区和产品声明;各功能拥有各自的操作、分区、行和条件式首次使用引导页面)。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。 外壳将首次使用引导记录按升序投影,在 body 层级的展示层中每次只挂载一个页面,同时将下层应用根节点标记为 `inert`。当前注册方会收到该条目的 id、`complete()` 和 `openSection(id)` 回调;完成或跳过当前页面后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案和变更操作均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。 diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css index 817ab38d9a..72c188e019 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -167,12 +167,22 @@ flex: none; display: flex; align-items: flex-start; - justify-content: flex-end; + justify-content: space-between; + gap: 8px; height: 54px; padding: 20px 14px 8px 10px; box-sizing: border-box; } +.actions { + min-width: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + margin-left: auto; +} + /* Close button (figma .Icon_container 501:29982): 28x28, r28, 14px glyph. */ .close { display: inline-flex; diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx index 3eefbd4ef1..45055753ac 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -76,6 +76,7 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP
+
{renderSlot('settings.action', {})}