From 9a2f353813d926d4cc1d1ea78b73b7f12d4f18f4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 24 Sep 2026 12:35:12 +0800 Subject: [PATCH 01/42] fix(web): open pending plan reviews across Sidebar seat changes A plan review that mounted in the same commit as a Sidebar seat change still crashed its opener, which retired the View full plan link and every automatic open until the page reloaded. Two paths reached the crash: - Returning from another Session: PlanReviewOpen read the departing seat as mounted during render, and that seat released before the review's effect ran while the arriving seat bound after it. - A Session without a saved Sidebar layout: the seat released and rebound around every store commit, and the commit that created the layout was the one in which the review opened. The seat now republishes its binding after each commit without releasing it in between, and releases the latest binding only when it leaves the screen. The review opener reads the mounted snapshot when it acts, reports whether an open reached a seat, retries whenever the mounted Session changes, and records the review as opened only after an open does. Embedded subagent reviews still open in the visible parent Sidebar. --- apps/web/tests/plan-review.e2e.ts | 126 +++++++++++++++++- packages/client/ui-plan/README.i18n.yaml | 4 +- packages/client/ui-plan/README.md | 2 +- packages/client/ui-plan/README.zh.md | 2 +- .../client/ui-plan/src/client/PlanCard.tsx | 24 ++-- packages/client/ui-plan/src/client/index.ts | 6 +- .../tests/browser-plugin.client.spec.ts | 10 +- .../tests/plan-preview.client.spec.tsx | 23 +++- .../client/ui-sidebar-right/README.i18n.yaml | 4 +- packages/client/ui-sidebar-right/README.md | 2 +- packages/client/ui-sidebar-right/README.zh.md | 2 +- .../ui-sidebar-right/src/client/service.ts | 16 ++- .../src/client/shell/SidebarRight.tsx | 27 ++-- .../tests/seat.client.spec.tsx | 19 +++ 14 files changed, 226 insertions(+), 41 deletions(-) diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index 788b6aa871..374ea97b0b 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/apps/web/tests/plan-review.e2e.ts @@ -16,9 +16,9 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { - assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, - launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, + launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -264,6 +264,128 @@ describe('web e2e: plan review takeover round trip', () => { }) }) +describe.skipIf(MODE === 'record')('web e2e: pending plan review across Sidebar seat changes', () => { + // Two recorded Sessions seeded side by side; opening one in the page gives it + // a live Agent, so a review can be delivered to it while it is off screen. + const OTHER_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url)) + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let planned: SessionId + let other: SessionId + const crashes: string[] = [] + const row = (session: SessionId) => page.locator(`[data-row-key="session:${session}"]`) + + async function showSessions(): Promise { + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await page.locator('[role="treeitem"]').first().waitFor({ timeout: 15_000 }) + if (await row(planned).count() === 0) await page.locator('[data-row-key^="workspace:"]').first().click() + await row(planned).waitFor({ timeout: 15_000 }) + } + + async function open(session: SessionId): Promise { + await row(session).click() + await page.locator(`[data-conversation-session="${session}"]`).waitFor({ timeout: 15_000 }) + await page.locator(`[data-sidebar-right-session="${session}"]:not([hidden])`).first().waitFor({ state: 'attached', timeout: 15_000 }) + // `expect.poll` is test-scoped and this also runs from beforeAll, so poll by hand. + const deadline = Date.now() + 15_000 + while (scaffold.ctx.agents.get(session) === undefined) { + if (Date.now() > deadline) throw new Error(`opening Session ${session} published no live Agent`) + await new Promise(resolve => setTimeout(resolve, 100)) + } + } + + async function showPlugins(): Promise { + await page.getByRole('button', { name: 'Plugins', exact: true }).click() + await expect.poll(() => page.locator('[data-composer-input]').count(), { timeout: 10_000 }).toBe(0) + } + + /** Deliver a plan review to one Session, run the scenario, and withdraw the review. */ + async function whileReviewing(session: SessionId, detail: string, scenario: () => Promise): Promise { + const agent = scaffold.ctx.agents.get(session) + if (agent === undefined) throw new Error(`Session ${session} has no live Agent`) + const controller = new AbortController() + const asked = scaffold.ctx.userQuestions.ask({ + agent, signal: controller.signal, + questions: [{ id: 'seat-change', question: 'Approve this plan?', detail, + options: [{ label: 'Approve' }, { label: 'Keep planning' }], intent: { kind: 'plan-review', approve: 'Approve' }, + }], + }) + const outcome = asked.then(value => value, (error: unknown) => ({ error })) + try { + await row(session).locator('[data-state="warning"]').waitFor({ timeout: 10_000 }) + await scenario() + } finally { + controller.abort() + await outcome + } + } + + /** The review opened itself in the Sidebar and kept its manual opener. */ + async function expectOpened(text: string): Promise { + const card = page.locator('[data-plan-review-key]') + await card.waitFor({ timeout: 10_000 }) + const preview = page.locator('[data-plan-preview^="dsh-resource://plan-review/"]') + await preview.waitFor({ state: 'visible', timeout: 10_000 }) + expect(await preview.getByText(text).isVisible()).toBe(true) + expect(await card.getByRole('button', { name: 'Open plan in sidebar' }).count()).toBe(1) + expect(await page.locator('[data-slot-error]').count()).toBe(0) + expect(crashes).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + planned = await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), 'plan-review-seat-planned') + other = await seedSession(scaffold, await readFile(OTHER_FIXTURE, 'utf8'), 'plan-review-seat-other') + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + page.on('console', (message: ConsoleMessage) => { + if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) crashes.push(message.text()) + }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await showSessions() + await open(planned) + await open(other) + }) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('opens a review that arrives while another Session is on screen', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-review-session-return')) + // Returning swaps the Sidebar seats in the commit that mounts the review: + // the departing seat releases before the review's effect, the arriving + // seat binds after it. + await whileReviewing(planned, '# Session review\n\nSubmitted while another Session was on screen.', async () => { + await row(planned).click() + await expectOpened('Submitted while another Session was on screen.') + }) + }) + + it('opens a review for a Session whose Sidebar layout was never saved', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-review-first-layout')) + // The layout is stored per browser. Without it the arriving seat creates + // one, and that store commit lands in the commit where the review opens. + await open(other) + const warningStart = tripwire.warnings.length + await page.evaluate((session) => { localStorage.removeItem(`dsh.sidebar-right.v1.${session}`) }, planned) + await page.reload({ waitUntil: 'load' }) + await showSessions() + acknowledgeReloadConnectionLoss(tripwire, warningStart) + await showPlugins() + await whileReviewing(planned, '# Layout review\n\nSubmitted to a Session without a saved Sidebar layout.', async () => { + await row(planned).click() + await expectOpened('Submitted to a Session without a saved Sidebar layout.') + }) + }) +}) + describe('web e2e: dismissed plan history', () => { it.skipIf(MODE === 'record')('reopens the permanent plan card after dismissing its review', async () => { // Replay supplies a finite continuation; the real question rejection and retained document are the assertions. diff --git a/packages/client/ui-plan/README.i18n.yaml b/packages/client/ui-plan/README.i18n.yaml index aec8a85468..106fd6defe 100644 --- a/packages/client/ui-plan/README.i18n.yaml +++ b/packages/client/ui-plan/README.i18n.yaml @@ -27,8 +27,8 @@ en: a13078ade4ef3b9d zh: 2c8b3e8b62ad5652 /deepseek-ai-dsh-client-ui-plan/understand-the-implementation: - en: 1360fc232aee29e7 - zh: 0beca76c9214a079 + en: 735fd84060c94816 + zh: 66f1ca0485b65046 /deepseek-ai-dsh-client-ui-plan/further-exploration: en: 59f62ef6269abf82 zh: c8b4ee607be1e432 diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md index e9e38251ff..db56c71f4a 100644 --- a/packages/client/ui-plan/README.md +++ b/packages/client/ui-plan/README.md @@ -51,7 +51,7 @@ Admission failures (`matched: false`, business errors, transport faults) surface The chip occupies the conversation-declared `conversation.input.plan` single seat; the node half is an empty apply (the roster row). Reads ride the generic projection pair through the standard-kit `useProjection`: the effective target is `pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way. The seat's injected face carries one verb, `exitPlanMode`, which executes `/plan off` through `ctx.remote.commands.execute` and maps admission failures to an inline error line. The placeholder and hint text live in ui-conversation's `conversation` locale namespace and are shared verbatim with the claimed `/plan` command hint. -Plan cards derive from native `tool/call` or PTC dispatch arguments through a Conversation Definition, with each invocation’s resolved Turn location. They contribute to the additive `conversation.chat.turnTail` list alongside file deliveries. The plan resource address identifies the invocation and its complete ordinary or direct-parent subagent Session address; its provider reads existing Session history, including older pages, without storing document text in sidebar layout. The question plugin owns the review action slot and supplies its request key, complete text, and optional invocation identity. The automatic open reads `ctx.sidebarRight.mounted` through a bound hook and runs once a seat is on screen: a review arriving while a global panel is active mounts ahead of the returning seat in the same commit, before that seat binds. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate. Subagent plan addresses also preserve unknown mode so history reads can resolve the child descriptor. +Plan cards derive from native `tool/call` or PTC dispatch arguments through a Conversation Definition, with each invocation’s resolved Turn location. They contribute to the additive `conversation.chat.turnTail` list alongside file deliveries. The plan resource address identifies the invocation and its complete ordinary or direct-parent subagent Session address; its provider reads existing Session history, including older pages, without storing document text in sidebar layout. The question plugin owns the review action slot and supplies its request key, complete text, and optional invocation identity. The automatic open reads `ctx.sidebarRight.mounted` through a bound hook, runs once a seat is on screen, and checks the seat again when it opens: a review can mount in the same commit as the seat, ahead of it, and a Session switch releases the departing seat before the review's effect runs. An open that finds no bound seat leaves the review unopened, and the next change of the mounted session retries it. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate. Subagent plan addresses also preserve unknown mode so history reads can resolve the child descriptor. The framework-bound `usePlans(turn)` exposes only submitted-plan data for that Turn. Chat indexes membership on Node updates and orders this collection when read; card rendering neither scans the transcript nor subscribes to other Turns or Node kinds. diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md index 91fe232d27..67c07e64ea 100644 --- a/packages/client/ui-plan/README.zh.md +++ b/packages/client/ui-plan/README.zh.md @@ -51,7 +51,7 @@ kind: "package-reference" 徽章占据 conversation 声明的 `conversation.input.plan` 单实例座位;node 半部是空 apply(roster 行)。读取经 standard-kit 的 `useProjection` 走通用投影对:有效目标是 `pending ? !active : active`——折叠的宿主值而非客户端乐观态,因此到达的帧无论哪个方向都会纠正徽章。座位注入面携带一个动词 `exitPlanMode`,经 `ctx.remote.commands.execute` 执行 `/plan off`,并把准入失败映射为一行内联错误。placeholder 与提示文案位于 ui-conversation 的 `conversation` locale 命名空间,与已认领 `/plan` 命令的提示逐字共用。 -计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识调用及完整的普通会话地址或子会话直接父级地址;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供请求键、完整正文和可选的调用标识。自动打开通过绑定的 hook 读取 `ctx.sidebarRight.mounted`,在座位出现在屏幕上后才执行:全局面板打开期间到达的审批,会在返回时与座位在同一次提交里先挂载、早于座位完成绑定。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。 子代理计划地址也保留未知模式,以便历史读取解析子 descriptor。 +计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识调用及完整的普通会话地址或子会话直接父级地址;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供请求键、完整正文和可选的调用标识。自动打开通过绑定的 hook 读取 `ctx.sidebarRight.mounted`,在座位出现在屏幕上后才执行,并在打开时再核对一次座位:审批可能与座位在同一次提交里、先于座位挂载,而切换会话会在审批的 effect 运行前释放离开的座位。没有找到已绑定座位的打开不会把审批记为已打开,挂载会话的下一次变化会重试它。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。 子代理计划地址也保留未知模式,以便历史读取解析子 descriptor。 框架绑定的 `usePlans(turn)` 只提供该回合的已提交计划数据。Chat 随节点更新维护成员索引,并在读取时排列该集合;卡片渲染不扫描整段对话,也不订阅其他回合或节点类型。 diff --git a/packages/client/ui-plan/src/client/PlanCard.tsx b/packages/client/ui-plan/src/client/PlanCard.tsx index b78454d0e7..78d2d94f73 100644 --- a/packages/client/ui-plan/src/client/PlanCard.tsx +++ b/packages/client/ui-plan/src/client/PlanCard.tsx @@ -27,8 +27,11 @@ export interface PlanCardsInjected extends PlanOpenInjected { /** Session-bound preview navigation for one pending review. */ export interface PlanReviewOpenInjected { - /** Open the logged plan, or the request's temporary document when no invocation exists. */ - openReview: (review: PropsRuntime<'conversation.plan-review.actions'>['review'], requestKey: string) => void + /** + * Open the logged plan, or the request's temporary document when no invocation exists. + * @returns whether a mounted Sidebar seat received the open; with none mounted nothing opens. + */ + openReview: (review: PropsRuntime<'conversation.plan-review.actions'>['review'], requestKey: string) => boolean readonly hooks: { /** The session whose right Sidebar seat is mounted; `undefined` while none is on screen. */ readonly sidebarMounted: HostObservable @@ -62,21 +65,22 @@ export function PlanCards({ turn, usePlans, openPlan, t }: PropsRuntime<'convers /** * Open each pending plan automatically and retain a manual opener without answering it. * - * The automatic open waits for a mounted Sidebar seat: a review that arrives - * while the Conversation is off screen mounts in the same commit as the seat, - * ahead of it, and the seat binds from its own effect. Reading the bound - * session through the hook opens once that binding exists. + * The automatic open waits for a mounted Sidebar seat and tries again whenever + * the mounted session changes. A review can mount in the same commit as the + * seat, ahead of it, and a Session switch releases the departing seat before + * the review's effect runs while the arriving seat binds after it; an open in + * that gap reaches no seat, so the review counts as opened only once an open + * does. * @param props - Review identity, Session store, localized copy, and navigation. * @returns an opener for either logged or temporary plan text. */ export function PlanReviewOpen({ review, requestKey, openReview, useSidebarMounted, t, useStore, actions }: PropsRuntime<'conversation.plan-review.actions'> & InjectFace & PropsLocale<'plan'> & PropsStore>) { const identity = review.callId === undefined ? `review:${requestKey}` : `call:${review.callId}` const opened = useStore(state => state.opened[identity] === true) - const mounted = useSidebarMounted(session => session !== undefined) + const mounted = useSidebarMounted(session => session) useEffect(() => { - if (opened || !mounted) return - openReview(review, requestKey) - actions.markOpened(identity) + if (opened || mounted === undefined) return + if (openReview(review, requestKey)) actions.markOpened(identity) }, [identity, opened, mounted, openReview, review, requestKey, actions]) return diff --git a/packages/client/ui-plan/src/client/index.ts b/packages/client/ui-plan/src/client/index.ts index 6dc0b66a2f..e0a137a060 100644 --- a/packages/client/ui-plan/src/client/index.ts +++ b/packages/client/ui-plan/src/client/index.ts @@ -98,10 +98,14 @@ export function apply(ctx: ClientContext): void { name: 'conversation.plan-review.actions', id: previewId, locale: NS, store: reviewStore, inject: (sessionId: SessionId): PlanReviewOpenInjected => ({ openReview: (review, requestKey) => { - if (review.callId !== undefined) { open(sessionId).openPlan(review.callId); return } + // Read at call time, not from a render: between a departing seat's + // release and the arriving seat's bind no seat can take the open. + if (ctx.sidebarRight.mounted.getSnapshot() === undefined) return false + if (review.callId !== undefined) { open(sessionId).openPlan(review.callId); return true } ctx.sidebarRight.openResource(reviewPreviewAddress(sessionId, `${reviewWindow}:${requestKey}`), { params: { planReview: { markdown: review.plan, title: extractMarkdownPlainText(review.plan, { mode: 'first-line' }) } }, }) + return true }, hooks: { sidebarMounted: ctx.sidebarRight.mounted }, }), diff --git a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts index 6567c45cb1..ae421af05d 100644 --- a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts @@ -117,6 +117,7 @@ describe('ui-plan browser apply', () => { closeWithFocus: (_paneId, close) => { close() }, openWithFocus: (open) => { open() }, }) + b.mounted.set(parent) b.openResource.mockImplementation(controller.openResource.bind(controller)) b.openResourceIn.mockImplementation(controller.openResourceIn.bind(controller)) b.subagentAddress.mockReturnValue({ parentSessionId: parent, childSessionId: child, mode: 'continuable' }) @@ -132,7 +133,7 @@ describe('ui-plan browser apply', () => { expect(store.getSnapshot().bySession[child]).toBeUndefined() const review = b.slots.entries('conversation.plan-review.actions')[0]! const reviewOpener = (review.inject as unknown as (sessionId: SessionId) => PlanReviewOpenInjected)(child) - reviewOpener.openReview({ id: 'pending', question: 'Approve?', plan: '# Temporary child plan', approve: { label: 'Approve' } }, 'child-question') + expect(reviewOpener.openReview({ id: 'pending', question: 'Approve?', plan: '# Temporary child plan', approve: { label: 'Approve' } }, 'child-question')).toBe(true) expect(resources().some(address => address.startsWith('dsh-resource://plan-review/embedded-child/'))).toBe(true) expect(store.getSnapshot().bySession[child]).toBeUndefined() } finally { @@ -223,7 +224,12 @@ describe('ui-plan browser apply', () => { // The automatic open reads the service's mounted-seat source, not a copy. expect(reviewInjected.hooks.sidebarMounted).toBe(b.mounted) const pending = { id: 'review', question: 'Approve?', plan: plan.markdown, callId: plan.callId, approve: { label: 'Approve' } } - reviewInjected.openReview(pending, 'question:1') + // A seat that released and has not rebound leaves nothing to open into. + expect(reviewInjected.openReview(pending, 'question:1')).toBe(false) + expect(b.openResource).toHaveBeenCalledOnce() + b.mounted.set(SID) + expect(reviewInjected.openReview(pending, 'question:1')).toBe(true) + expect(b.openResource).toHaveBeenCalledTimes(2) expect(b.openResource).toHaveBeenLastCalledWith(address) b.subagentAddress.mockReturnValue({ parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' }) injected.openPlan(plan.callId) diff --git a/packages/client/ui-plan/tests/plan-preview.client.spec.tsx b/packages/client/ui-plan/tests/plan-preview.client.spec.tsx index 73e08d4b2d..ac1a2fbbf3 100644 --- a/packages/client/ui-plan/tests/plan-preview.client.spec.tsx +++ b/packages/client/ui-plan/tests/plan-preview.client.spec.tsx @@ -234,7 +234,7 @@ describe('plan entry points and document', () => { expect(view.container.innerHTML).toBe('') }) it.each([true, false])('opens once, preserves manual closure, and reopens a review (logged: %s)', (logged) => { - const openReview = vi.fn() + const openReview = vi.fn(() => true) const store = createPlanReviewStore().create() const review = { plan: markdown, ...(logged ? { callId: plan.callId } : {}) } const props = { ...reviewProps(review, openReview, store), useSidebarMounted: seatHook(() => target.session.sessionId) } @@ -260,7 +260,7 @@ describe('plan entry points and document', () => { it('waits for a mounted sidebar seat before opening automatically, then opens once', () => { // The review and the seat mount in one commit, the review first: its effect // runs while no seat is bound, and the seat's own effect binds afterwards. - const openReview = vi.fn() + const openReview = vi.fn(() => true) const store = createPlanReviewStore().create() const mounted = createSnapshotStore(undefined) const review = { plan: markdown, callId: plan.callId } @@ -282,6 +282,25 @@ describe('plan entry points and document', () => { view.rerender() expect(openReview).toHaveBeenCalledTimes(2) }) + it('retries the automatic open when the mounted seat changes after an open found none bound', () => { + // Switching Sessions mounts the review while the departing seat is the + // mounted one; that seat releases and the arriving one binds around the + // review's effect, so the first open finds no bound seat. + const bound = { current: false } + const openReview = vi.fn(() => bound.current) + const store = createPlanReviewStore().create() + const mounted = createSnapshotStore('departing' as SessionId) + const review = { plan: markdown, callId: plan.callId } + const props = { ...reviewProps(review, openReview, store), useSidebarMounted: seatHook(() => mounted.getSnapshot()) } + const view = render() + expect(openReview).toHaveBeenCalledOnce() + expect(store.getSnapshot().opened).toEqual({}) + bound.current = true + mounted.set(target.session.sessionId) + view.rerender() + expect(openReview).toHaveBeenCalledTimes(2) + expect(store.getSnapshot().opened).toEqual({ [`call:${plan.callId}`]: true }) + }) it('renders temporary Markdown and reports expired navigation after reload', () => { const address = reviewPreviewAddress(target.session.sessionId, 'window:question:1') expect(isReviewPreviewAddress(address)).toBe(true) diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml index b66d407510..a68e00b118 100644 --- a/packages/client/ui-sidebar-right/README.i18n.yaml +++ b/packages/client/ui-sidebar-right/README.i18n.yaml @@ -30,8 +30,8 @@ en: ef2af36136c4a919 zh: 48d992e2c923a130 /deepseek-ai-dsh-client-ui-sidebar-right/ctx-sidebarright: - en: 8cffef22360aa177 - zh: a09eef5bb7fdbd61 + en: 6d2a888b1941f1bf + zh: a6c23283366eef77 /deepseek-ai-dsh-client-ui-sidebar-right/the-tab-domain: en: 717b86e05b44ebdd zh: 0f4f694d0ea1d7f8 diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md index 2f851c638e..41a297fc8d 100644 --- a/packages/client/ui-sidebar-right/README.md +++ b/packages/client/ui-sidebar-right/README.md @@ -94,7 +94,7 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r `preferNewPane: true` first asks the target docked pane to split under the normal two-pane and room rules, then falls back to that pane when no split is available. `replaceTab` takes precedence and disables this preference. -`close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; `toggleFullscreen(target)` applies the panel control’s display-mode action to a captured dock pane. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the mounted surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands need a mounted session surface; with none, they throw rather than write into a surface nobody draws. `mounted` is the observable of the session whose seat is on screen, `undefined` while a global panel replaces the Conversation or no session is selected. A component that opens content from its own mount effect reads it through a bound hook and acts once it is defined: the frame mounts the Conversation column ahead of this seat, and the seat publishes its binding from a passive effect of the same commit, so an effect that assumed a bound seat would run first and throw. The plan review's automatic open reads it this way. +`close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; `toggleFullscreen(target)` applies the panel control’s display-mode action to a captured dock pane. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the mounted surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands need a mounted session surface; with none, they throw rather than write into a surface nobody draws. `mounted` is the observable of the session whose seat is on screen, `undefined` while a global panel replaces the Conversation or no session is selected; it changes only when a seat arrives or leaves, because the seat republishes its binding after each commit without releasing it in between. A component that opens content from its own effect reads it through a bound hook, acts once it is defined, checks `getSnapshot()` again when it acts, and retries when the value changes: the frame mounts the Conversation column ahead of this seat, so in a commit that switches Sessions the departing seat releases before the Conversation's effects run and the arriving seat binds after them. The plan review's automatic open reads it this way. `focusedTarget(element?)` resolves the current DOM pane and tab, including an embedding iframe; outside focus returns no page. `commandTarget(element?)` additionally allows an outside open to use the mounted Session's active dock pane. Captures include the occurrence and navigation revision; `isTargetCurrent(target)` rejects changed Sessions, moved or reopened tabs, and intervening navigation. Focus and pointer activation remain separate from persisted layout selection. diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md index c45ba23db9..019dca39f4 100644 --- a/packages/client/ui-sidebar-right/README.zh.md +++ b/packages/client/ui-sidebar-right/README.zh.md @@ -94,7 +94,7 @@ tab 类型分两阶段注册,随包发布的引导类型走的正是别的包 `preferNewPane: true` 会先按普通的两格上限与空间规则尝试分割目标停靠格,无法分栏时回退到该格。`replaceTab` 优先并会禁用这项偏好。 -`close(tabId)` 关闭一个 tab;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;`toggleFullscreen(target)` 对捕获的停靠分栏执行面板控件的显示模式操作。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进已挂载停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令需要一个已挂载的会话停靠面;没有时它们 throw,而不是写进一个没人绘制的面里。`mounted` 是「席位正在屏幕上的那个会话」的可观察值,全局面板替代 Conversation 或没有选中会话时为 `undefined`。要在自己的挂载 effect 里打开内容的组件通过绑定的 hook 读取它,并在它有值后再行动:框架把 Conversation 列挂载在这个席位之前,而席位在同一次提交的 passive effect 里才发布绑定,一个假定席位已绑定的 effect 会先运行并 throw。计划审阅的自动打开就是这样读取它的。 +`close(tabId)` 关闭一个 tab;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;`toggleFullscreen(target)` 对捕获的停靠分栏执行面板控件的显示模式操作。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进已挂载停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令需要一个已挂载的会话停靠面;没有时它们 throw,而不是写进一个没人绘制的面里。`mounted` 是「席位正在屏幕上的那个会话」的可观察值,全局面板替代 Conversation 或没有选中会话时为 `undefined`;它只在席位出现或离开时变化,因为席位在每次提交后重新发布绑定,中间不释放。要在自己的 effect 里打开内容的组件通过绑定的 hook 读取它,在它有值后再行动,行动时再用 `getSnapshot()` 核对一次,并在值变化时重试:框架把 Conversation 列挂载在这个席位之前,所以在一次切换会话的提交里,离开的席位先于 Conversation 的 effect 释放,到来的席位在它们之后才绑定。计划审阅的自动打开就是这样读取它的。 `focusedTarget(element?)` 解析当前 DOM 分栏和标签页,也接受嵌入 iframe;焦点位于外部时不返回页面。`commandTarget(element?)` 另外允许外部打开动作使用已挂载会话的活动停靠分栏。捕获值包含 occurrence 和导航版本;`isTargetCurrent(target)` 拒绝会话变更、已移动或重新打开的标签页,以及期间发生的导航。焦点和指针激活独立于持久化的布局选择。 diff --git a/packages/client/ui-sidebar-right/src/client/service.ts b/packages/client/ui-sidebar-right/src/client/service.ts index 193d40873b..6159e74173 100644 --- a/packages/client/ui-sidebar-right/src/client/service.ts +++ b/packages/client/ui-sidebar-right/src/client/service.ts @@ -161,11 +161,14 @@ export type SidebarRightCloseHandler = (sessionId: SessionId, tab: TabRecord) => export interface ISidebarRight { /** * The session whose seat is mounted, or `undefined` while no seat is on - * screen (a global panel is active, or no session is selected). Moves when a - * seat binds or releases, so a component that opens content from its own - * mount effect reads it through a bound hook and acts once it is defined: - * the frame mounts the Conversation column ahead of the seat, and the seat - * publishes its binding from a passive effect of the same commit. + * screen (a global panel is active, or no session is selected). Moves only + * when a seat binds or releases; a seat republishing its binding for the + * same session is silent. A component that opens content from its own + * effect reads it through a bound hook, acts once it is defined, reads + * `getSnapshot()` again when it acts, and retries when the value changes: + * the frame mounts the Conversation column ahead of the seat, so in a + * commit that switches sessions the departing seat releases before the + * Conversation's effects run and the arriving seat binds after them. */ readonly mounted: ObservableSnapshot /** @@ -288,7 +291,8 @@ export class SidebarRightController implements ISidebarRight { /** * Adopt the mounted seat's binding, replacing any previous one. * - * Called from the seat while it is mounted, and released when it leaves. + * Called from the seat after each commit while it is active, and released + * when it leaves. * @param binding - the mounted seat's session, actions, and the store's surfaces. * @returns a release callback that clears exactly this binding. */ diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx index a1fcac0617..8d5f1a7a79 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx @@ -81,10 +81,12 @@ export interface SidebarRightInjected { * Publish this seat's session, actions, and the store's surfaces to `ctx.sidebarRight`. * * The service is root-scoped and cannot read a per-entry store, so the only - * honest source is the mounted seat. Held for as long as the seat is mounted; - * the service publishes the bound session through `ctx.sidebarRight.mounted`. + * honest source is the mounted seat. The seat calls it after each commit + * while it is active, each call replacing the previous binding, and calls + * the latest release when it leaves the screen; the service publishes the + * bound session through `ctx.sidebarRight.mounted`. * @param binding - what a command needs to act on this session, and what a tab's own action needs to act on its. - * @returns a release callback. + * @returns a release callback that clears this binding while it is still the latest. */ readonly bindService: (binding: { sessionId: SessionId @@ -420,16 +422,21 @@ export function RightbarSeat({ : undefined, [syncPresentation, active]) // Republished on every committed change: the service's readers answer from the - // last commit, and its commands act on the session actually on screen. - useEffect( - () => active - ? bindService({ sessionId, actions, surfaces, autoFullscreen, + // last commit, and its commands act on the session actually on screen. A + // republish replaces the previous binding without releasing it, because a + // release between them would leave the service unbound while the rest of + // that commit's effects run; the latest binding is released only when the + // seat leaves the screen. + const release = useRef<(() => void) | undefined>(undefined) + useEffect(() => { + if (active) { + release.current = bindService({ sessionId, actions, surfaces, autoFullscreen, closeWithFocus: (paneId, close) => { closeWithPaneFocus(document, sessionId, paneId, close) }, openWithFocus: (open) => { openWithPaneFocus(document, sessionId, open) }, canSplitPane: paneId => room.current.get(paneId)?.row !== false }) - : undefined, - [bindService, sessionId, actions, surfaces, autoFullscreen, active], - ) + } + }, [bindService, sessionId, actions, surfaces, autoFullscreen, active]) + useEffect(() => active ? () => { release.current?.() } : undefined, [active]) // The Tab domain is not synced here: the controller adopted this session's // store as the runtime minted it and reconciles on the store's own commits, // on screen or not. diff --git a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx index cc11d1c863..6c18b886e7 100644 --- a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx @@ -343,6 +343,25 @@ describe('RightbarSeat presentation', () => { expect(document.querySelector('[data-slot-error]')).toBeNull() }) + it('stays bound through its own store commits', async () => { + const h = await mountSeat(1440, true, 0, true) + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + const published: (SessionId | undefined)[] = [] + const stop = h.controller.mounted.subscribe(() => { published.push(h.controller.mounted.getSnapshot()) }) + try { + // The opener mounts in the commit that also carries a store commit of the + // seat, and its effect runs before the seat's own effects. + act(() => { + h.actions.toggleExpanded(SESSION) + h.arm(true) + }) + expect(published).toEqual([]) + expect(h.opened).toHaveLength(1) + expect(Object.values(h.layout().tabs).map(tab => tab.contentId)).toContain(h.opened[0]) + expect(errors.mock.calls.map(call => String(call[0])).filter(text => text.includes('slot entry crashed'))).toEqual([]) + } finally { stop() } + }) + it.each([0, 1, 2])('selects the default from %i guide entries and protects only a sole guide', async (entryCount) => { const h = await mountSeat(1440, true, entryCount) act(() => { h.controller.toggleExpanded() }) From ebb6b03378bbbff1eb6c911b155bd4054b89aa99 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 27 Sep 2026 16:43:45 +0800 Subject: [PATCH 02/42] fix(sidebar-right): name the on-screen Session instead of a seat binding ctx.sidebarRight acted on a binding the right Sidebar seat published from passive effects. React runs a commit's passive effects in tree order and the frame renders the Conversation column before the right column, so an opener mounting in the commit that switched Sessions or returned from a global panel ran while no seat was bound, and its open threw. The plugin now names the on-screen Session itself: the selected Session while the Conversation fills the main column, derived from the Sidebar's Session views and ctx.layout.panelInfo. Both notify before React renders, so `mounted` already names the arriving Session during that commit, and its seat mints the Session's store in the same render. Public commands act through the Session's adopted store; the seat reports only the room its docking kit measured, and focus continuity and the auto-fullscreen rule come from a host the plugin supplies. - ctx.layout gains viewportWidth from the root layout store. - SidebarRightBinding, controller.bind(), and the seat's bindService are removed; the seat's inject face gains measureRoom. - ui-plan's review opener returns to a plain open gated on `mounted`. - The plan-review e2e expands the Workspace group by its aria state. --- ...sidebar-tab-types-and-navigation.i18n.yaml | 12 +- ...-09-05-sidebar-tab-types-and-navigation.md | 6 +- ...-05-sidebar-tab-types-and-navigation.zh.md | 6 +- apps/web/tests/plan-review.e2e.ts | 12 +- packages/client/ui-layout/README.i18n.yaml | 4 +- packages/client/ui-layout/README.md | 2 +- packages/client/ui-layout/README.zh.md | 2 +- packages/client/ui-layout/src/client/index.ts | 6 +- .../client/ui-layout/src/client/service.ts | 4 + .../ui-layout/tests/apply.client.spec.ts | 18 ++ .../ui-layout/tests/service.client.spec.ts | 15 +- .../tests/browser-plugin.client.spec.ts | 3 + packages/client/ui-plan/README.i18n.yaml | 4 +- packages/client/ui-plan/README.md | 2 +- packages/client/ui-plan/README.zh.md | 2 +- .../client/ui-plan/src/client/PlanCard.tsx | 26 +- packages/client/ui-plan/src/client/index.ts | 6 +- .../tests/browser-plugin.client.spec.ts | 26 +- .../tests/plan-preview.client.spec.tsx | 34 +-- .../tests/document-seat.client.spec.tsx | 2 +- .../client/ui-sidebar-right/README.i18n.yaml | 12 +- packages/client/ui-sidebar-right/README.md | 8 +- packages/client/ui-sidebar-right/README.zh.md | 8 +- .../ui-sidebar-right/src/client/index.ts | 36 ++- .../ui-sidebar-right/src/client/service.ts | 283 +++++++++--------- .../src/client/shell/SidebarRight.tsx | 70 ++--- .../tests/apply.client.spec.ts | 71 +++-- .../tests/focus.client.spec.ts | 35 ++- .../tests/persistence.client.spec.ts | 6 +- .../tests/seat.client.spec.tsx | 46 ++- .../tests/service.client.spec.ts | 106 +++---- .../tests/panel-list.client.spec.tsx | 1 + .../tests/workspaces-service.client.spec.ts | 2 +- 33 files changed, 463 insertions(+), 413 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml index f7b369d628..bd3e2ebb5e 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml @@ -21,8 +21,8 @@ en: 774c3bf80a48ffbe zh: c513245789aa8efd /agent-note-right-sidebar-tab-types-and-navigation/decision/navigation-ctx-sidebarright: - en: b32bad4c3b8c2cc8 - zh: 57ba7b8d3451e478 + en: c70040a8041e3d06 + zh: a6958514a35733c4 /agent-note-right-sidebar-tab-types-and-navigation/decision/addresses: en: eeb1e7e7478a2f2c zh: bddbc5bc78245f63 @@ -30,8 +30,8 @@ en: 6386bde2654f9a0a zh: 700439be6a01e162 /agent-note-right-sidebar-tab-types-and-navigation/alternatives-considered: - en: 91fbca006e8de5bc - zh: f48d3ce1dd7b63bf + en: c47eb4e2f3c307f9 + zh: fd4515b8be88506e /agent-note-right-sidebar-tab-types-and-navigation/consequences: en: 3cdb1159ee55d851 zh: dcd068005e678a89 @@ -39,5 +39,5 @@ en: 49449a6c8465c098 zh: 1f476cc1226aa9cd /agent-note-right-sidebar-tab-types-and-navigation/deferred: - en: fa27b7684e38329b - zh: d270e0cdcab73732 + en: 5b2416692028bf89 + zh: 65930102aa253bf2 diff --git a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md index 599ce36ed4..ba21a60ce3 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md +++ b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md @@ -72,7 +72,7 @@ interface SidebarRightTabParamsMap {} // key: kind — a page type declar `openResource` accepts the union of every declared resource shape and `openTab` the shape declared for `K`; a body narrows `navigation.params` by the protocol or kind it knows it serves. Parameters belong to the resource type rather than to the viewer because a line number is a fact about a file location, not about the text preview, and any type that claims `file` addresses receives the same shape. Values must be JSON-serializable, and a record must be rebuildable from address and parameters alone, because undo, redo, reload, and HMR rebuild tabs after the opener is gone. -Beside the two opens, the face carries `close(tabId)`, `active()`, `isExpanded()`, `toggleExpanded()`, and four operational methods — `focus(tabId)`, `split(paneId?)` (returning the new pane, or `undefined` when the pane budget or the width rule forbids the split, recording nothing), `float(tabId, rect?)`, and `dock(paneId)` — each recording one history entry and a no-op on a missing target or one already in the requested state. There is no layout snapshot, no subscription, and no lookup by address: the face grants control over the layout, not a view of it. The seat publishes its binding — its session, its store's actions, and its surface — while mounted; a command on the public face acts on the mounted session and throws with no mounted session surface. A tab's own actions reach their session's own store instead: the slot runtime mints one store per session, the plugin adopts each as it is minted, and the controller routes by session id, so an action fired after the user switched sessions still lands, and does nothing for a session whose store was never minted. +Beside the two opens, the face carries `close(tabId)`, `active()`, `isExpanded()`, `toggleExpanded()`, and four operational methods — `focus(tabId)`, `split(paneId?)` (returning the new pane, or `undefined` when the pane budget or the width rule forbids the split, recording nothing), `float(tabId, rect?)`, and `dock(paneId)` — each recording one history entry and a no-op on a missing target or one already in the requested state. There is no layout snapshot, no subscription, and no lookup by address: the face grants control over the layout, not a view of it. The plugin names the Session on screen — the selected Session while the Conversation fills the main column — from the Session selection and the main panel, before React renders either change, and publishes it as `mounted`; a command on the public face acts on that Session through its adopted store and throws when no Session is on screen or its store has not been minted. The seat reports only the room its docking kit measured. A tab's own actions reach their session's own store instead: the slot runtime mints one store per session, the plugin adopts each as it is minted, and the controller routes by session id, so an action fired after the user switched sessions still lands, and does nothing for a session whose store was never minted. ### Addresses @@ -100,6 +100,8 @@ The conversation's `openFile(path, { line? })` — tool-row path links, produced **Slot priorities to express an extension taking over a builtin, then registry-minted slot keys.** The first attempt had the overriding type register its body at a lower slot priority through an exported constant — a value import across dynamic plugins, and a second rule system (slot priority) standing in for the registry's. The second attempt had the registry mint a key per registration and return it from `register()`, which made registration a two-step dance whose ordering mattered. Letting the implementation declare its own `id` — required, unique, the same string it registers its seats under — needs no constant, no minting, and no ordering, and gives the registry the identity it needs to reject duplicates. +**A binding the seat publishes from its own effects.** The first form had the mounted seat publish its session, actions, and surface after each commit and release them when it left. React runs a commit's passive effects in tree order, and the frame renders the Conversation column before the right column, so in a commit that switched Sessions or returned from a global panel a Conversation component opening content from its effect ran while no seat was bound — after the departing seat's release and before the arriving seat's bind — and the open threw. The Session selection and the main panel change before React renders, so deriving the on-screen Session from them removes that ordering dependency. The seat still measures pane room, because only its rendered kit can. + ## Consequences - A type is one static object plus one or two keyed seat registrations; its occurrence information is read through injected `useTabInfo()`. The framework grows no per-type API surface, and a type shipped from outside this repository imports only types from the Sidebar package. @@ -116,5 +118,5 @@ The conversation's `openFile(path, { line? })` — tool-row path links, produced ## Deferred - A navigation protocol beyond `sidebar://`: sub-routes within a page, naming an implementation, and the ecosystem-facing rules for other navigation schemes. -- Opening into a session other than the one on screen from the public face, which acts on the mounted session only; a tab's own actions already act on their tab's session. +- Opening into a session other than the one on screen from the public face, which acts on the on-screen Session only; a tab's own actions already act on their tab's session. - A localized message when an open fails from the conversation; the failure is currently the thrown error's text. diff --git a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md index e4e8de5a38..a5d357b9b7 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md @@ -72,7 +72,7 @@ interface SidebarRightTabParamsMap {} // key: kind — a page type declar `openResource` 接受所有已声明资源形状的联合,`openTab` 接受为 `K` 声明的形状;正文按自己所服务的协议或 kind 收窄 `navigation.params`。参数属于资源类型而非查看器,因为行号是关于文件位置的事实,不是关于文本预览的,任何认领 `file` 地址的类型收到同一形状。值必须可 JSON 序列化,一条记录必须只凭地址与参数就能重建,因为撤销、重做、刷新与 HMR 都在开启方已不在时重建 tab。 -除两种打开外,该面还有 `close(tabId)`、`active()`、`isExpanded()`、`toggleExpanded()`,以及四个操作型方法——`focus(tabId)`、`split(paneId?)`(返回新格,预算或宽度规则不允许分栏时返回 `undefined` 且不记账)、`float(tabId, rect?)` 与 `dock(paneId)`——每个记一条历史,目标不存在或已在目标态时为 no-op。没有布局快照、没有订阅、没有按地址查找:该面给的是对布局的控制权,不是布局的视图。座位挂载期间发布其绑定——自己的会话、其 store 的 action 与其面;公开面上的命令作用于已挂载会话,没有已挂载会话面时抛错。tab 自己的动作则到达其会话自己的 store:slot 运行时每个会话铸一个 store,插件在铸出时逐个收养,控制器按会话 id 路由,因此用户切换会话之后触发的动作照样落地,而 store 从未铸出的会话什么也不做。 +除两种打开外,该面还有 `close(tabId)`、`active()`、`isExpanded()`、`toggleExpanded()`,以及四个操作型方法——`focus(tabId)`、`split(paneId?)`(返回新格,预算或宽度规则不允许分栏时返回 `undefined` 且不记账)、`float(tabId, rect?)` 与 `dock(paneId)`——每个记一条历史,目标不存在或已在目标态时为 no-op。没有布局快照、没有订阅、没有按地址查找:该面给的是对布局的控制权,不是布局的视图。插件依据会话选择与主面板确定屏上会话——Conversation 占据主栏时的选中会话——并在 React 渲染这两者的变化之前将其发布为 `mounted`;公开面上的命令经该会话被收养的 store 作用于它,没有会话在屏上或其 store 尚未铸出时抛错。座位只上报其停靠套件测得的空间。tab 自己的动作则到达其会话自己的 store:slot 运行时每个会话铸一个 store,插件在铸出时逐个收养,控制器按会话 id 路由,因此用户切换会话之后触发的动作照样落地,而 store 从未铸出的会话什么也不做。 ### 地址 @@ -100,6 +100,8 @@ interface SidebarRightTabParamsMap {} // key: kind — a page type declar **用 Slot 优先级表达 extension 接管 builtin,随后是注册表铸造的坑位键。** 第一次尝试让覆盖方经一个导出常量以更低的 Slot 优先级注册正文——这是动态插件间的值引用,也是拿第二套规则(Slot 优先级)替注册表的规则站台。第二次尝试让注册表为每次注册铸一个键并从 `register()` 返回,这把注册变成了两步且顺序敏感的舞步。让实现自己声明 `id`——必填、唯一、与它注册坑位所用的同一个串——既不需要常量,也不需要铸键与顺序,还给了注册表拒绝重复所需的身份。 +**由座位在自己的 effect 里发布绑定。** 最初的形式由已挂载的座位在每次提交后发布自己的会话、action 与面,离开时释放。React 按树序运行一次提交的被动 effect,而框架先渲染 Conversation 列、后渲染右栏,于是在切换会话或从全局面板返回的那次提交里,从自己 effect 打开内容的 Conversation 组件运行时没有任何座位已绑定——在离开的座位释放之后、到来的座位绑定之前——打开随之抛错。会话选择与主面板都在 React 渲染之前变化,从它们推导屏上会话便消除了这一顺序依赖。分栏空间仍由座位测量,因为只有它渲染出的套件能测。 + ## Consequences - 一个类型 = 一个静态对象 + 一到两个 keyed 坑位注册;其实例信息通过注入的 `useTabInfo()` 读取。框架不长任何按类型的 API 面,仓外类型从 Sidebar 包只引类型。 @@ -116,5 +118,5 @@ interface SidebarRightTabParamsMap {} // key: kind — a page type declar ## Deferred - `sidebar://` 之外的导航协议:页内子路由、点名实现、以及面向生态的其它导航 scheme 规则。 -- 从公开面往屏上会话之外的会话里打开;公开面只作用于已挂载的会话,而 tab 自己的动作已作用于其所在会话。 +- 从公开面往屏上会话之外的会话里打开;公开面只作用于屏上会话,而 tab 自己的动作已作用于其所在会话。 - 从会话区打开失败时的本地化提示;目前是抛错文本本身。 diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index 374ea97b0b..ea2a30d938 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/apps/web/tests/plan-review.e2e.ts @@ -279,8 +279,10 @@ describe.skipIf(MODE === 'record')('web e2e: pending plan review across Sidebar async function showSessions(): Promise { await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - await page.locator('[role="treeitem"]').first().waitFor({ timeout: 15_000 }) - if (await row(planned).count() === 0) await page.locator('[data-row-key^="workspace:"]').first().click() + // Expand the Workspace group by its state, not by whether its rows have rendered yet. + const group = page.locator('[data-row-key^="workspace:"]').first() + await group.waitFor({ timeout: 15_000 }) + if (await group.getAttribute('aria-expanded') !== 'true') await group.click() await row(planned).waitFor({ timeout: 15_000 }) } @@ -359,9 +361,9 @@ describe.skipIf(MODE === 'record')('web e2e: pending plan review across Sidebar it('opens a review that arrives while another Session is on screen', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-review-session-return')) - // Returning swaps the Sidebar seats in the commit that mounts the review: - // the departing seat releases before the review's effect, the arriving - // seat binds after it. + // Returning mounts the review in the commit that brings its Session back on + // screen and swaps the Sidebar seats; the review's automatic open runs + // before either seat's effects. await whileReviewing(planned, '# Session review\n\nSubmitted while another Session was on screen.', async () => { await row(planned).click() await expectOpened('Submitted while another Session was on screen.') diff --git a/packages/client/ui-layout/README.i18n.yaml b/packages/client/ui-layout/README.i18n.yaml index 24a9220917..8db8483954 100644 --- a/packages/client/ui-layout/README.i18n.yaml +++ b/packages/client/ui-layout/README.i18n.yaml @@ -24,8 +24,8 @@ en: 1e1d3361dac7d8b0 zh: b6b3d210d65738f0 /deepseek-ai-dsh-client-ui-layout/understand-the-implementation: - en: 8af800fa55786c71 - zh: 7421c12eb3585c21 + en: d3a95bd98997ff42 + zh: cad20230a114a1ec /deepseek-ai-dsh-client-ui-layout/further-exploration: en: b6c728b6ed70f2a7 zh: 8ee2003b98695323 diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index e1fab07fff..60a7d10b7d 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -51,7 +51,7 @@ The presenter consumes resolved theme snapshots and projects them onto the docum `selectPanel(id)` checks the live `main` registry before changing selection; an absent key throws and leaves the current panel intact. `beginNavigation()` returns an abort signal for an asynchronous UI navigation. A later call, a valid panel selection (including repeated selection), or layout disposal aborts that signal without cancelling underlying Session creation. Consumers check the signal before committing navigation or moving drafts. -One registration declares five child slots and binds `ctx.layout` methods `selectPanel`, `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. One root store separates `panelInfo` selection from `layoutInfo` measurements, width preferences, and presentation reports. `ctx.layout.panelInfo` and the standard `usePanelInfo` hook share the same selection source; AppFrame subscribes to the stable layout object. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame keeps the column containers mounted. The right column's root controller renders `rightbar.session` through `SessionProvider` only while the Conversation is selected; its unmount report releases the track. The independent title component uses the selected Session title only while the Conversation is visible, with the build-configured product title or localized `common.brand.localBuild` as its fallback; locale revisions update that fallback. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions. +One registration declares five child slots and binds `ctx.layout` methods `selectPanel`, `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. One root store separates `panelInfo` selection from `layoutInfo` measurements, width preferences, and presentation reports. `ctx.layout.panelInfo` and the standard `usePanelInfo` hook share the same selection source; `ctx.layout.viewportWidth` publishes the frame's measured width from the same store, the value the `rightbar` owner receives as `viewportWidth`. AppFrame subscribes to the stable layout object. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame keeps the column containers mounted. The right column's root controller renders `rightbar.session` through `SessionProvider` only while the Conversation is selected; its unmount report releases the track. The independent title component uses the selected Session title only while the Conversation is visible, with the build-configured product title or localized `common.brand.localBuild` as its fallback; locale revisions update that fallback. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions. diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md index 29d0633b7f..5d8e47fe40 100644 --- a/packages/client/ui-layout/README.zh.md +++ b/packages/client/ui-layout/README.zh.md @@ -51,7 +51,7 @@ Windows Electron 的 `data-windows-titlebar` 标记在所有列上方预留顶 `selectPanel(id)` 在改变选中态前检查实时 `main` 注册表;缺失的 key 会抛错并保留当前面板。`beginNavigation()` 为异步 UI 导航返回 abort signal。后续调用、有效面板选择(包括重复选择)或布局释放会中止该 signal,但不取消底层会话创建。消费方在提交导航或搬移草稿前检查 signal。 -一次注册声明五个子 slot,并绑定 `ctx.layout` 的 `selectPanel`、`toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。同一个 root 存储把 `panelInfo` 选中态与 `layoutInfo` 测量、宽度偏好、呈现报告分开。`ctx.layout.panelInfo` 与标准 `usePanelInfo` hook 共用同一个选中态来源;AppFrame 订阅引用稳定的布局对象。`rightbar` owner 提供实际 `width`、`viewportWidth`,以及表示能否以普通模式呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 保持各列容器挂载。右栏的 root 控制器仅在选中会话界面时,经 `SessionProvider` 渲染 `rightbar.session`;内容卸载时的报告释放轨道。独立的标题组件仅在会话界面可见时使用所选会话标题,以构建配置的产品标题或本地化 `common.brand.localBuild` 为回退值;语言变化会更新该回退值。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列布局。退出全屏时,框架先保持无过渡并安装目标布局:关闭移除右轨道,恢复保留右轨道。后续普通几何操作恢复正常过渡。 +一次注册声明五个子 slot,并绑定 `ctx.layout` 的 `selectPanel`、`toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。同一个 root 存储把 `panelInfo` 选中态与 `layoutInfo` 测量、宽度偏好、呈现报告分开。`ctx.layout.panelInfo` 与标准 `usePanelInfo` hook 共用同一个选中态来源;`ctx.layout.viewportWidth` 从同一存储发布框架测得的宽度,即 `rightbar` owner 收到的 `viewportWidth`。AppFrame 订阅引用稳定的布局对象。`rightbar` owner 提供实际 `width`、`viewportWidth`,以及表示能否以普通模式呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 保持各列容器挂载。右栏的 root 控制器仅在选中会话界面时,经 `SessionProvider` 渲染 `rightbar.session`;内容卸载时的报告释放轨道。独立的标题组件仅在会话界面可见时使用所选会话标题,以构建配置的产品标题或本地化 `common.brand.localBuild` 为回退值;语言变化会更新该回退值。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列布局。退出全屏时,框架先保持无过渡并安装目标布局:关闭移除右轨道,恢复保留右轨道。后续普通几何操作恢复正常过渡。 diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index 410c8b0977..da1f7513c0 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -164,8 +164,12 @@ export function apply(ctx: ClientContext): void { getSnapshot: () => instance.getSnapshot().panelInfo, subscribe: listener => instance.subscribe(listener), } + const viewportWidth: HostObservable = { + getSnapshot: () => instance.getSnapshot().layoutInfo.viewportWidth, + subscribe: listener => instance.subscribe(listener), + } const layout = new LayoutController(instance.actions, id => - ctx.slots.entries('main').some(entry => entry.options.key === id), panelInfo) + ctx.slots.entries('main').some(entry => entry.options.key === id), panelInfo, viewportWidth) const disposePanelInfo = ctx.slots.provideRoot({ hooks: { panelInfo: layout.panelInfo } }) const disposeService = ctx.reflect.provide('layout', layout) const disposeRegistration = ctx.slots.register({ diff --git a/packages/client/ui-layout/src/client/service.ts b/packages/client/ui-layout/src/client/service.ts index 22dd77a4e6..2719d30f0a 100644 --- a/packages/client/ui-layout/src/client/service.ts +++ b/packages/client/ui-layout/src/client/service.ts @@ -28,6 +28,8 @@ export type PanelActions = BoundActions> export interface ILayout { /** Selected central panel from the same root store used by `usePanelInfo`. */ readonly panelInfo: HostObservable + /** The frame's measured width in CSS pixels, from the same root store; the `rightbar` owner receives it as `viewportWidth`. */ + readonly viewportWidth: HostObservable /** * Select a global central panel without changing the current Session. * @param panelId - registered main key, or null to show the Conversation. @@ -61,11 +63,13 @@ export class LayoutController implements ILayout { * @param panels - actions of the instance shared with the root entry. * @param hasMainPanel - checks the live main-slot registry for a panel id. * @param panelInfo - root store's shared central-panel selection source. + * @param viewportWidth - root store's measured frame width source. */ constructor( private readonly panels: PanelActions, private readonly hasMainPanel: (id: MainPanelId) => boolean, readonly panelInfo: HostObservable, + readonly viewportWidth: HostObservable, ) {} /** Select a global panel or return to the Conversation. */ diff --git a/packages/client/ui-layout/tests/apply.client.spec.ts b/packages/client/ui-layout/tests/apply.client.spec.ts index c38719c68d..5c1cf328ac 100644 --- a/packages/client/ui-layout/tests/apply.client.spec.ts +++ b/packages/client/ui-layout/tests/apply.client.spec.ts @@ -95,6 +95,24 @@ describe('ui-layout client apply', () => { expect(slots.spec('shell.leading')).toEqual({ kind: 'single', scope: 'root' }) }) + it('publishes the frame\'s measured viewport width from the root store', async () => { + const { ctx, slots } = await bench() + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + const instance = (slots.entries('root')[0]!.store as ReturnType).create() + const layout = ctx.get('layout') as LayoutController + const seen: number[] = [] + const unsubscribe = layout.viewportWidth.subscribe(() => { seen.push(layout.viewportWidth.getSnapshot()) }) + try { + instance.actions.setViewportWidth(700) + expect(layout.viewportWidth.getSnapshot()).toBe(700) + expect(seen).toContain(700) + } finally { + unsubscribe() + await fiber.dispose() + } + }) + it('shares a pre-created instance between service actions, root rendering, and panelInfo', async () => { const { ctx, slots, rendererHost } = await bench() const fiber = ctx.plugin({ inject: [...inject], apply }) diff --git a/packages/client/ui-layout/tests/service.client.spec.ts b/packages/client/ui-layout/tests/service.client.spec.ts index b3e3680d5c..6fe0593c06 100644 --- a/packages/client/ui-layout/tests/service.client.spec.ts +++ b/packages/client/ui-layout/tests/service.client.spec.ts @@ -19,7 +19,7 @@ function fakePanels(): PanelActions { describe('LayoutController', () => { it('forwards right column transitions to the constructor-supplied actions', () => { const panels = fakePanels() - const service = new LayoutController(panels, () => true, createSnapshotStore({ activePanelId: null })) + const service = new LayoutController(panels, () => true, createSnapshotStore({ activePanelId: null }), createSnapshotStore(1440)) service.openRightbar(true, false) service.openRightbar(true, true) @@ -36,7 +36,7 @@ describe('LayoutController', () => { it('can toggle the sidebar immediately after construction', () => { const panels = fakePanels() - const service = new LayoutController(panels, () => true, createSnapshotStore({ activePanelId: null })) + const service = new LayoutController(panels, () => true, createSnapshotStore({ activePanelId: null }), createSnapshotStore(1440)) service.toggleSidebar() @@ -46,7 +46,7 @@ describe('LayoutController', () => { it('forwards panel selection and returning to the Conversation without changing geometry', () => { const panels = fakePanels() - const service = new LayoutController(panels, () => true, createSnapshotStore({ activePanelId: null })) + const service = new LayoutController(panels, () => true, createSnapshotStore({ activePanelId: null }), createSnapshotStore(1440)) const panelId = 'panel-a' as MainPanelId service.selectPanel(panelId) service.selectPanel(panelId) @@ -62,8 +62,8 @@ describe('LayoutController', () => { it('keeps separately constructed controllers bound to their own instances', () => { const first = fakePanels() const second = fakePanels() - const firstService = new LayoutController(first, () => true, createSnapshotStore({ activePanelId: null })) - const secondService = new LayoutController(second, () => true, createSnapshotStore({ activePanelId: null })) + const firstService = new LayoutController(first, () => true, createSnapshotStore({ activePanelId: null }), createSnapshotStore(1440)) + const secondService = new LayoutController(second, () => true, createSnapshotStore({ activePanelId: null }), createSnapshotStore(1440)) firstService.toggleSidebar() expect(first.toggleSidebar).toHaveBeenCalledTimes(1) expect(second.toggleSidebar).not.toHaveBeenCalled() @@ -75,7 +75,8 @@ describe('LayoutController', () => { it('rejects an absent main entry without changing selection or cancelling pending navigation', () => { const panels = fakePanels() const present = new Set(['panel-a']) - const service = new LayoutController(panels, id => present.has(id), createSnapshotStore({ activePanelId: null })) + const service = new LayoutController( + panels, id => present.has(id), createSnapshotStore({ activePanelId: null }), createSnapshotStore(1440)) service.selectPanel('panel-a' as MainPanelId) const navigation = service.beginNavigation() present.delete('panel-a') @@ -87,7 +88,7 @@ describe('LayoutController', () => { }) it('supersedes asynchronous navigation on another request, any valid selection, and disposal', () => { - const service = new LayoutController(fakePanels(), () => true, createSnapshotStore({ activePanelId: null })) + const service = new LayoutController(fakePanels(), () => true, createSnapshotStore({ activePanelId: null }), createSnapshotStore(1440)) const first = service.beginNavigation() const second = service.beginNavigation() expect(first.aborted).toBe(true) diff --git a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts index 2fce5eb64e..7bf9688646 100644 --- a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts @@ -57,6 +57,9 @@ async function bench() { const layout = new LayoutController(layoutStore.actions, id => id === 'plugins', { getSnapshot: () => layoutStore.getSnapshot().panelInfo, subscribe: listener => layoutStore.subscribe(listener), + }, { + getSnapshot: () => layoutStore.getSnapshot().layoutInfo.viewportWidth, + subscribe: listener => layoutStore.subscribe(listener), }) ctx.provide('layout', layout) ctx.provide('sessions', { list }) diff --git a/packages/client/ui-plan/README.i18n.yaml b/packages/client/ui-plan/README.i18n.yaml index 106fd6defe..29501d3d4e 100644 --- a/packages/client/ui-plan/README.i18n.yaml +++ b/packages/client/ui-plan/README.i18n.yaml @@ -27,8 +27,8 @@ en: a13078ade4ef3b9d zh: 2c8b3e8b62ad5652 /deepseek-ai-dsh-client-ui-plan/understand-the-implementation: - en: 735fd84060c94816 - zh: 66f1ca0485b65046 + en: 9b663a9a30286847 + zh: 9dca5298fce6a6dd /deepseek-ai-dsh-client-ui-plan/further-exploration: en: 59f62ef6269abf82 zh: c8b4ee607be1e432 diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md index db56c71f4a..e02091c34b 100644 --- a/packages/client/ui-plan/README.md +++ b/packages/client/ui-plan/README.md @@ -51,7 +51,7 @@ Admission failures (`matched: false`, business errors, transport faults) surface The chip occupies the conversation-declared `conversation.input.plan` single seat; the node half is an empty apply (the roster row). Reads ride the generic projection pair through the standard-kit `useProjection`: the effective target is `pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way. The seat's injected face carries one verb, `exitPlanMode`, which executes `/plan off` through `ctx.remote.commands.execute` and maps admission failures to an inline error line. The placeholder and hint text live in ui-conversation's `conversation` locale namespace and are shared verbatim with the claimed `/plan` command hint. -Plan cards derive from native `tool/call` or PTC dispatch arguments through a Conversation Definition, with each invocation’s resolved Turn location. They contribute to the additive `conversation.chat.turnTail` list alongside file deliveries. The plan resource address identifies the invocation and its complete ordinary or direct-parent subagent Session address; its provider reads existing Session history, including older pages, without storing document text in sidebar layout. The question plugin owns the review action slot and supplies its request key, complete text, and optional invocation identity. The automatic open reads `ctx.sidebarRight.mounted` through a bound hook, runs once a seat is on screen, and checks the seat again when it opens: a review can mount in the same commit as the seat, ahead of it, and a Session switch releases the departing seat before the review's effect runs. An open that finds no bound seat leaves the review unopened, and the next change of the mounted session retries it. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate. Subagent plan addresses also preserve unknown mode so history reads can resolve the child descriptor. +Plan cards derive from native `tool/call` or PTC dispatch arguments through a Conversation Definition, with each invocation’s resolved Turn location. They contribute to the additive `conversation.chat.turnTail` list alongside file deliveries. The plan resource address identifies the invocation and its complete ordinary or direct-parent subagent Session address; its provider reads existing Session history, including older pages, without storing document text in sidebar layout. The question plugin owns the review action slot and supplies its request key, complete text, and optional invocation identity. The automatic open reads `ctx.sidebarRight.mounted` through a bound hook and runs once a Session is on screen: the Sidebar names the returning Session before the commit that mounts the review renders, so a review that arrived while a global panel or another Session was on screen opens from that commit's effect. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate. Subagent plan addresses also preserve unknown mode so history reads can resolve the child descriptor. The framework-bound `usePlans(turn)` exposes only submitted-plan data for that Turn. Chat indexes membership on Node updates and orders this collection when read; card rendering neither scans the transcript nor subscribes to other Turns or Node kinds. diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md index 67c07e64ea..5bb898b582 100644 --- a/packages/client/ui-plan/README.zh.md +++ b/packages/client/ui-plan/README.zh.md @@ -51,7 +51,7 @@ kind: "package-reference" 徽章占据 conversation 声明的 `conversation.input.plan` 单实例座位;node 半部是空 apply(roster 行)。读取经 standard-kit 的 `useProjection` 走通用投影对:有效目标是 `pending ? !active : active`——折叠的宿主值而非客户端乐观态,因此到达的帧无论哪个方向都会纠正徽章。座位注入面携带一个动词 `exitPlanMode`,经 `ctx.remote.commands.execute` 执行 `/plan off`,并把准入失败映射为一行内联错误。placeholder 与提示文案位于 ui-conversation 的 `conversation` locale 命名空间,与已认领 `/plan` 命令的提示逐字共用。 -计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识调用及完整的普通会话地址或子会话直接父级地址;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供请求键、完整正文和可选的调用标识。自动打开通过绑定的 hook 读取 `ctx.sidebarRight.mounted`,在座位出现在屏幕上后才执行,并在打开时再核对一次座位:审批可能与座位在同一次提交里、先于座位挂载,而切换会话会在审批的 effect 运行前释放离开的座位。没有找到已绑定座位的打开不会把审批记为已打开,挂载会话的下一次变化会重试它。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。 子代理计划地址也保留未知模式,以便历史读取解析子 descriptor。 +计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识调用及完整的普通会话地址或子会话直接父级地址;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供请求键、完整正文和可选的调用标识。自动打开通过绑定的 hook 读取 `ctx.sidebarRight.mounted`,在有会话出现在屏幕上后才执行:Sidebar 在挂载审批的那次提交渲染之前就已给出返回的会话,因此在全局面板或其他会话占据屏幕期间到达的审批,会在那次提交的 effect 里打开。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。 子代理计划地址也保留未知模式,以便历史读取解析子 descriptor。 框架绑定的 `usePlans(turn)` 只提供该回合的已提交计划数据。Chat 随节点更新维护成员索引,并在读取时排列该集合;卡片渲染不扫描整段对话,也不订阅其他回合或节点类型。 diff --git a/packages/client/ui-plan/src/client/PlanCard.tsx b/packages/client/ui-plan/src/client/PlanCard.tsx index 78d2d94f73..e629b024c3 100644 --- a/packages/client/ui-plan/src/client/PlanCard.tsx +++ b/packages/client/ui-plan/src/client/PlanCard.tsx @@ -27,13 +27,10 @@ export interface PlanCardsInjected extends PlanOpenInjected { /** Session-bound preview navigation for one pending review. */ export interface PlanReviewOpenInjected { - /** - * Open the logged plan, or the request's temporary document when no invocation exists. - * @returns whether a mounted Sidebar seat received the open; with none mounted nothing opens. - */ - openReview: (review: PropsRuntime<'conversation.plan-review.actions'>['review'], requestKey: string) => boolean + /** Open the logged plan, or the request's temporary document when no invocation exists. */ + openReview: (review: PropsRuntime<'conversation.plan-review.actions'>['review'], requestKey: string) => void readonly hooks: { - /** The session whose right Sidebar seat is mounted; `undefined` while none is on screen. */ + /** The Session on screen, whose right Sidebar the opens land in; `undefined` while none is. */ readonly sidebarMounted: HostObservable } } @@ -65,22 +62,21 @@ export function PlanCards({ turn, usePlans, openPlan, t }: PropsRuntime<'convers /** * Open each pending plan automatically and retain a manual opener without answering it. * - * The automatic open waits for a mounted Sidebar seat and tries again whenever - * the mounted session changes. A review can mount in the same commit as the - * seat, ahead of it, and a Session switch releases the departing seat before - * the review's effect runs while the arriving seat binds after it; an open in - * that gap reaches no seat, so the review counts as opened only once an open - * does. + * The automatic open waits for a Session on screen. `ctx.sidebarRight` names + * the Session before the commit that brings its Conversation back renders, so + * a review that arrived while the Conversation was off screen opens from the + * effect of the commit that mounts it. * @param props - Review identity, Session store, localized copy, and navigation. * @returns an opener for either logged or temporary plan text. */ export function PlanReviewOpen({ review, requestKey, openReview, useSidebarMounted, t, useStore, actions }: PropsRuntime<'conversation.plan-review.actions'> & InjectFace & PropsLocale<'plan'> & PropsStore>) { const identity = review.callId === undefined ? `review:${requestKey}` : `call:${review.callId}` const opened = useStore(state => state.opened[identity] === true) - const mounted = useSidebarMounted(session => session) + const mounted = useSidebarMounted(session => session !== undefined) useEffect(() => { - if (opened || mounted === undefined) return - if (openReview(review, requestKey)) actions.markOpened(identity) + if (opened || !mounted) return + openReview(review, requestKey) + actions.markOpened(identity) }, [identity, opened, mounted, openReview, review, requestKey, actions]) return diff --git a/packages/client/ui-plan/src/client/index.ts b/packages/client/ui-plan/src/client/index.ts index e0a137a060..6dc0b66a2f 100644 --- a/packages/client/ui-plan/src/client/index.ts +++ b/packages/client/ui-plan/src/client/index.ts @@ -98,14 +98,10 @@ export function apply(ctx: ClientContext): void { name: 'conversation.plan-review.actions', id: previewId, locale: NS, store: reviewStore, inject: (sessionId: SessionId): PlanReviewOpenInjected => ({ openReview: (review, requestKey) => { - // Read at call time, not from a render: between a departing seat's - // release and the arriving seat's bind no seat can take the open. - if (ctx.sidebarRight.mounted.getSnapshot() === undefined) return false - if (review.callId !== undefined) { open(sessionId).openPlan(review.callId); return true } + if (review.callId !== undefined) { open(sessionId).openPlan(review.callId); return } ctx.sidebarRight.openResource(reviewPreviewAddress(sessionId, `${reviewWindow}:${requestKey}`), { params: { planReview: { markdown: review.plan, title: extractMarkdownPlainText(review.plan, { mode: 'first-line' }) } }, }) - return true }, hooks: { sidebarMounted: ctx.sidebarRight.mounted }, }), diff --git a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts index ae421af05d..266ddeda6a 100644 --- a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts @@ -109,15 +109,14 @@ describe('ui-plan browser apply', () => { const tabs = new SidebarRightTabRegistry(b.ctx) b.registerType.mockImplementation(definition => tabs.register(definition)) const store = createSidebarRightStore(() => ({ kind: 'guide', title: 'Guide' })).create() - const { controller, adopt } = createSidebarRightController(tabs, vi.fn()) + const { controller, adopt, show } = createSidebarRightController(tabs, vi.fn(), { + autoFullscreen: () => false, + openWithFocus: (_sessionId, open) => { open() }, + closeWithFocus: (_sessionId, _paneId, close) => { close() }, + }) const release = adopt(parent, store) store.actions.open(parent) - const unbind = controller.bind({ - sessionId: parent, actions: store.actions, surfaces: store.getSnapshot().bySession, canSplitPane: () => true, - closeWithFocus: (_paneId, close) => { close() }, - openWithFocus: (open) => { open() }, - }) - b.mounted.set(parent) + show(parent) b.openResource.mockImplementation(controller.openResource.bind(controller)) b.openResourceIn.mockImplementation(controller.openResourceIn.bind(controller)) b.subagentAddress.mockReturnValue({ parentSessionId: parent, childSessionId: child, mode: 'continuable' }) @@ -133,12 +132,12 @@ describe('ui-plan browser apply', () => { expect(store.getSnapshot().bySession[child]).toBeUndefined() const review = b.slots.entries('conversation.plan-review.actions')[0]! const reviewOpener = (review.inject as unknown as (sessionId: SessionId) => PlanReviewOpenInjected)(child) - expect(reviewOpener.openReview({ id: 'pending', question: 'Approve?', plan: '# Temporary child plan', approve: { label: 'Approve' } }, 'child-question')).toBe(true) + reviewOpener.openReview({ id: 'pending', question: 'Approve?', plan: '# Temporary child plan', approve: { label: 'Approve' } }, 'child-question') expect(resources().some(address => address.startsWith('dsh-resource://plan-review/embedded-child/'))).toBe(true) expect(store.getSnapshot().bySession[child]).toBeUndefined() } finally { await fiber.dispose() - unbind() + show(undefined) release() controller.tabDomain.dispose() } @@ -221,15 +220,10 @@ describe('ui-plan browser apply', () => { const review = b.slots.entries('conversation.plan-review.actions')[0]! expect(review.component).toBe(PlanReviewOpen) const reviewInjected = (review.inject as unknown as (sessionId: SessionId) => PlanReviewOpenInjected)(SID) - // The automatic open reads the service's mounted-seat source, not a copy. + // The automatic open reads the service's on-screen Session source, not a copy. expect(reviewInjected.hooks.sidebarMounted).toBe(b.mounted) const pending = { id: 'review', question: 'Approve?', plan: plan.markdown, callId: plan.callId, approve: { label: 'Approve' } } - // A seat that released and has not rebound leaves nothing to open into. - expect(reviewInjected.openReview(pending, 'question:1')).toBe(false) - expect(b.openResource).toHaveBeenCalledOnce() - b.mounted.set(SID) - expect(reviewInjected.openReview(pending, 'question:1')).toBe(true) - expect(b.openResource).toHaveBeenCalledTimes(2) + reviewInjected.openReview(pending, 'question:1') expect(b.openResource).toHaveBeenLastCalledWith(address) b.subagentAddress.mockReturnValue({ parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' }) injected.openPlan(plan.callId) diff --git a/packages/client/ui-plan/tests/plan-preview.client.spec.tsx b/packages/client/ui-plan/tests/plan-preview.client.spec.tsx index ac1a2fbbf3..1a4fa9aac9 100644 --- a/packages/client/ui-plan/tests/plan-preview.client.spec.tsx +++ b/packages/client/ui-plan/tests/plan-preview.client.spec.tsx @@ -29,13 +29,13 @@ function planHook(plans: readonly typeof plan[]): Parameters[0 const source = createSnapshotStore(plans) return keyedObservableHook(() => source) as Parameters[0]['usePlans'] } -/** The opener's props share; each test supplies the seat hook beside it. */ +/** The opener's props share; each test supplies the on-screen Session hook beside it. */ function reviewProps(review: { plan: string; callId?: typeof plan.callId }, openReview: Mock, store: ReturnType['create']>) { return { review, requestKey: 'question:1', t, openReview, actions: store.actions, useStore: (select: (state: ReturnType) => unknown) => select(store.getSnapshot()), } as unknown as Parameters[0] } -/** A bound `useSidebarMounted` reading one observable seat value. */ +/** A bound `useSidebarMounted` reading one on-screen Session value. */ function seatHook(read: () => SessionId | undefined) { return (select: (session: SessionId | undefined) => S): S => select(read()) } @@ -234,7 +234,7 @@ describe('plan entry points and document', () => { expect(view.container.innerHTML).toBe('') }) it.each([true, false])('opens once, preserves manual closure, and reopens a review (logged: %s)', (logged) => { - const openReview = vi.fn(() => true) + const openReview = vi.fn() const store = createPlanReviewStore().create() const review = { plan: markdown, ...(logged ? { callId: plan.callId } : {}) } const props = { ...reviewProps(review, openReview, store), useSidebarMounted: seatHook(() => target.session.sessionId) } @@ -257,10 +257,9 @@ describe('plan entry points and document', () => { } as Parameters[0]} />) expect(openReview).toHaveBeenCalledTimes(4) }) - it('waits for a mounted sidebar seat before opening automatically, then opens once', () => { - // The review and the seat mount in one commit, the review first: its effect - // runs while no seat is bound, and the seat's own effect binds afterwards. - const openReview = vi.fn(() => true) + it('waits for a Session on screen before opening automatically, then opens once', () => { + // A retained Sidebar can hold the review while no Session is on screen. + const openReview = vi.fn() const store = createPlanReviewStore().create() const mounted = createSnapshotStore(undefined) const review = { plan: markdown, callId: plan.callId } @@ -268,7 +267,7 @@ describe('plan entry points and document', () => { const view = render() expect(openReview).not.toHaveBeenCalled() expect(store.getSnapshot().opened).toEqual({}) - // The manual opener stays available without a seat; the service decides what to do. + // The manual opener stays available without a Session on screen; the service decides what to do. fireEvent.click(screen.getByRole('button', { name: 'Open plan in sidebar' })) expect(openReview).toHaveBeenCalledTimes(1) mounted.set(target.session.sessionId) @@ -282,25 +281,6 @@ describe('plan entry points and document', () => { view.rerender() expect(openReview).toHaveBeenCalledTimes(2) }) - it('retries the automatic open when the mounted seat changes after an open found none bound', () => { - // Switching Sessions mounts the review while the departing seat is the - // mounted one; that seat releases and the arriving one binds around the - // review's effect, so the first open finds no bound seat. - const bound = { current: false } - const openReview = vi.fn(() => bound.current) - const store = createPlanReviewStore().create() - const mounted = createSnapshotStore('departing' as SessionId) - const review = { plan: markdown, callId: plan.callId } - const props = { ...reviewProps(review, openReview, store), useSidebarMounted: seatHook(() => mounted.getSnapshot()) } - const view = render() - expect(openReview).toHaveBeenCalledOnce() - expect(store.getSnapshot().opened).toEqual({}) - bound.current = true - mounted.set(target.session.sessionId) - view.rerender() - expect(openReview).toHaveBeenCalledTimes(2) - expect(store.getSnapshot().opened).toEqual({ [`call:${plan.callId}`]: true }) - }) it('renders temporary Markdown and reports expired navigation after reload', () => { const address = reviewPreviewAddress(target.session.sessionId, 'window:question:1') expect(isReviewPreviewAddress(address)).toBe(true) diff --git a/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx index 3cfb4fe189..e9a44767f0 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx @@ -40,7 +40,7 @@ async function boot() { const rt = await SlotTestRuntime.create() runtime = rt rt.ctx.provide('configForms', { developerTools: { enabled: createSnapshotStore(true) } } as never) - rt.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never) + rt.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn(), panelInfo: rt.panelInfo, viewportWidth: createSnapshotStore(1440) } as never) rt.ctx.provide('shortcuts', { register: () => () => {}, catalog: { getSnapshot: () => SHORTCUT_CATALOG, subscribe: () => () => {} } } as never) const locale = new LocaleRuntime(rt.ctx) rt.ctx.provide('locale', locale) diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml index b85e2d8371..4821d5d917 100644 --- a/packages/client/ui-sidebar-right/README.i18n.yaml +++ b/packages/client/ui-sidebar-right/README.i18n.yaml @@ -21,8 +21,8 @@ en: 6d7f145ecb1fd140 zh: 666d142ef249f6cb /deepseek-ai-dsh-client-ui-sidebar-right/the-expand-button: - en: 28b0bc3d4050b0e0 - zh: 733b44ad9309f49a + en: 2614d3f179290606 + zh: 88c08ee260ba57b7 /deepseek-ai-dsh-client-ui-sidebar-right/state: en: 2b15cdf5f1383291 zh: 57018036d77363ba @@ -30,8 +30,8 @@ en: ef2af36136c4a919 zh: 48d992e2c923a130 /deepseek-ai-dsh-client-ui-sidebar-right/ctx-sidebarright: - en: 6d2a888b1941f1bf - zh: a6c23283366eef77 + en: 1b4b0a47625d91c1 + zh: 7878c3dcaa4c2811 /deepseek-ai-dsh-client-ui-sidebar-right/the-tab-domain: en: 717b86e05b44ebdd zh: 0f4f694d0ea1d7f8 @@ -51,5 +51,5 @@ en: 76a763b4dd71babd zh: fdc966dd427a82fb /deepseek-ai-dsh-client-ui-sidebar-right/known-limitations-and-deferred-work/dev-note: - en: 33274d681fac9a21 - zh: e024c1c4766b289f + en: 9fcbcccf09b43c5d + zh: 224b8a0881ab6890 diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md index df557c07c8..3b19cb7fec 100644 --- a/packages/client/ui-sidebar-right/README.md +++ b/packages/client/ui-sidebar-right/README.md @@ -58,7 +58,7 @@ While the panel is hidden, one button in the conversation header's corner seat ( The panel takes the conversation's ground colour and content font sizes rather than a raised layer of its own: it is a column of the page, not a card over it. -The root-scoped `rightbar` entry renders the selected Session and background Sessions with initialized `keepMounted` bodies through separate `rightbar.session` subtrees. Each View owns its Session reference. Only the foreground Conversation reports a frame track and binds public navigation; other subtrees stay hidden without deleting tab state. +The root-scoped `rightbar` entry renders the selected Session and background Sessions with initialized `keepMounted` bodies through separate `rightbar.session` subtrees. Each View owns its Session reference. Only the foreground subtree reports a frame track; other subtrees stay hidden without deleting tab state. ## State @@ -94,9 +94,9 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r `preferNewPane: true` first asks the target docked pane to split under the normal two-pane and room rules, then falls back to that pane when no split is available. `replaceTab` takes precedence and disables this preference. -`close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; `toggleFullscreen(target)` applies the panel control’s display-mode action to a captured dock pane. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the mounted surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands need a mounted session surface; with none, they throw rather than write into a surface nobody draws. `mounted` is the observable of the session whose seat is on screen, `undefined` while a global panel replaces the Conversation or no session is selected; it changes only when a seat arrives or leaves, because the seat republishes its binding after each commit without releasing it in between. A component that opens content from its own effect reads it through a bound hook, acts once it is defined, checks `getSnapshot()` again when it acts, and retries when the value changes: the frame mounts the Conversation column ahead of this seat, so in a commit that switches Sessions the departing seat releases before the Conversation's effects run and the arriving seat binds after them. The plan review's automatic open reads it this way. +`close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; `toggleFullscreen(target)` applies the panel control’s display-mode action to a captured dock pane. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the on-screen surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands act on the on-screen Session through its adopted store; with no Session on screen, or before the runtime mints its store, they throw rather than write into a surface nobody draws. `mounted` is the observable of the Session on screen: the selected Session while the Conversation fills the main column, `undefined` while a global panel replaces it or no Session is selected. The plugin derives it from the Session selection and `ctx.layout.panelInfo`, and it changes before React renders the change that causes it, so a component rendered in that commit reads the arriving Session and may act on it from its effects: the frame renders that Session's seat in the same pass, which mints its store. Seats never publish it; each reports only the room its docking kit measured, which `split` and `preferNewPane` apply to that Session's panes, and unmeasured panes have room. The plan review's automatic open reads `mounted` through a bound hook. -`focusedTarget(element?)` resolves the current DOM pane and tab, including an embedding iframe; outside focus returns no page. `commandTarget(element?)` additionally allows an outside open to use the mounted Session's active dock pane. Captures include the occurrence and navigation revision; `isTargetCurrent(target)` rejects changed Sessions, moved or reopened tabs, and intervening navigation. Focus and pointer activation remain separate from persisted layout selection. +`focusedTarget(element?)` resolves the current DOM pane and tab, including an embedding iframe; outside focus returns no page. `commandTarget(element?)` additionally allows an outside open to use the on-screen Session's active dock pane. Captures include the occurrence and navigation revision; `isTargetCurrent(target)` rejects changed Sessions, moved or reopened tabs, and intervening navigation. Focus and pointer activation remain separate from persisted layout selection. ## The Tab domain @@ -156,4 +156,4 @@ None. -**Runtime invariant:** No companion is published. The two services (`sidebarRight`, `sidebarRightTabs`) are provided through `ctx.reflect.provide` inside one effect and torn down with it; the seat's binding and the Tab domain's occurrence lifetimes are asserted directly by this package's specs, and no independent observation exists to diverge from them. +**Runtime invariant:** No companion is published. The two services (`sidebarRight`, `sidebarRightTabs`) are provided through `ctx.reflect.provide` inside one effect and torn down with it; the on-screen Session and the Tab domain's occurrence lifetimes are asserted directly by this package's specs, and no independent observation exists to diverge from them. diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md index 5a15ca66e3..9aa5a99954 100644 --- a/packages/client/ui-sidebar-right/README.zh.md +++ b/packages/client/ui-sidebar-right/README.zh.md @@ -58,7 +58,7 @@ kind: "package-reference" 面板取会话区的底色与正文字号,而不是自成一层浮起的表面:它是页面的一列,不是压在页面上的卡片。 -root 作用域的 `rightbar` 入口通过独立的 `rightbar.session` 子树渲染选中的 Session,以及拥有已初始化 `keepMounted` 正文的后台 Session。每个 View 拥有自身的 Session reference。只有前台 Conversation 上报框架列宽并绑定公共导航;其他子树保持隐藏,不删除 tab 状态。 +root 作用域的 `rightbar` 入口通过独立的 `rightbar.session` 子树渲染选中的 Session,以及拥有已初始化 `keepMounted` 正文的后台 Session。每个 View 拥有自身的 Session reference。只有前台子树上报框架列宽;其他子树保持隐藏,不删除 tab 状态。 ## 状态 @@ -94,9 +94,9 @@ tab 类型分两阶段注册,随包发布的引导类型走的正是别的包 `preferNewPane: true` 会先按普通的两格上限与空间规则尝试分割目标停靠格,无法分栏时回退到该格。`replaceTab` 优先并会禁用这项偏好。 -`close(tabId)` 关闭一个 tab;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;`toggleFullscreen(target)` 对捕获的停靠分栏执行面板控件的显示模式操作。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进已挂载停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令需要一个已挂载的会话停靠面;没有时它们 throw,而不是写进一个没人绘制的面里。`mounted` 是「席位正在屏幕上的那个会话」的可观察值,全局面板替代 Conversation 或没有选中会话时为 `undefined`;它只在席位出现或离开时变化,因为席位在每次提交后重新发布绑定,中间不释放。要在自己的 effect 里打开内容的组件通过绑定的 hook 读取它,在它有值后再行动,行动时再用 `getSnapshot()` 核对一次,并在值变化时重试:框架把 Conversation 列挂载在这个席位之前,所以在一次切换会话的提交里,离开的席位先于 Conversation 的 effect 释放,到来的席位在它们之后才绑定。计划审阅的自动打开就是这样读取它的。 +`close(tabId)` 关闭一个 tab;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;`toggleFullscreen(target)` 对捕获的停靠分栏执行面板控件的显示模式操作。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进屏幕上停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令通过屏幕上会话已采用的 store 作用于该会话;没有会话在屏幕上、或运行时尚未创建它的 store 时,命令 throw,而不是写进一个没人绘制的面里。`mounted` 是「屏幕上的会话」的可观察值:Conversation 占据主栏时为选中的会话,全局面板替代它或没有选中会话时为 `undefined`。插件从会话选择与 `ctx.layout.panelInfo` 推导它,它在 React 渲染引起变化的那次更新之前就已改变,因此同一次提交里渲染的组件读到的是到来的会话,并可以在自己的 effect 里对它行动:框架在同一轮渲染里渲染该会话的席位,席位随之创建它的 store。席位从不发布它;每个席位只上报停靠套件测得的空间,`split` 与 `preferNewPane` 把它用于该会话的分栏,未测量的分栏视为有空间。计划审阅的自动打开通过绑定的 hook 读取 `mounted`。 -`focusedTarget(element?)` 解析当前 DOM 分栏和标签页,也接受嵌入 iframe;焦点位于外部时不返回页面。`commandTarget(element?)` 另外允许外部打开动作使用已挂载会话的活动停靠分栏。捕获值包含 occurrence 和导航版本;`isTargetCurrent(target)` 拒绝会话变更、已移动或重新打开的标签页,以及期间发生的导航。焦点和指针激活独立于持久化的布局选择。 +`focusedTarget(element?)` 解析当前 DOM 分栏和标签页,也接受嵌入 iframe;焦点位于外部时不返回页面。`commandTarget(element?)` 另外允许外部打开动作使用屏幕上会话的活动停靠分栏。捕获值包含 occurrence 和导航版本;`isTargetCurrent(target)` 拒绝会话变更、已移动或重新打开的标签页,以及期间发生的导航。焦点和指针激活独立于持久化的布局选择。 ## Tab 域 @@ -156,4 +156,4 @@ Tab 域按(Session,Tab id)保留品牌化 occurrence id、导航、中止 -**运行时不变量:** 不发布 companion。两个服务(`sidebarRight`、`sidebarRightTabs`)在同一个 effect 内经 `ctx.reflect.provide` 提供并随之拆除;席位绑定与 Tab 域 occurrence 的生命周期由本包的 spec 直接断言,不存在会与之分歧的独立观察。 +**运行时不变量:** 不发布 companion。两个服务(`sidebarRight`、`sidebarRightTabs`)在同一个 effect 内经 `ctx.reflect.provide` 提供并随之拆除;屏幕上的会话与 Tab 域 occurrence 的生命周期由本包的 spec 直接断言,不存在会与之分歧的独立观察。 diff --git a/packages/client/ui-sidebar-right/src/client/index.ts b/packages/client/ui-sidebar-right/src/client/index.ts index 2c0613698a..e21ff3e85f 100644 --- a/packages/client/ui-sidebar-right/src/client/index.ts +++ b/packages/client/ui-sidebar-right/src/client/index.ts @@ -35,7 +35,8 @@ import type {} from './contract/slots.ts' import { GuideBody, type GuideInjected } from './tabs/guide/GuideBody.tsx' import { GuideTitle } from './tabs/guide/GuideTitle.tsx' import { ExpandButton } from './shell/ExpandButton.tsx' -import { RightbarSeat, type SidebarRightInjected } from './shell/SidebarRight.tsx' +import { AUTO_FULLSCREEN_BELOW, RightbarSeat, type SidebarRightInjected } from './shell/SidebarRight.tsx' +import { closeWithPaneFocus, openWithPaneFocus } from './shell/close-focus.ts' import { RightbarRoot, type RightbarRootInjected } from './shell/RightbarRoot.tsx' import { SidebarSessionViews } from './session-views.ts' import { createSidebarRightController, type SidebarRightController } from './service.ts' @@ -53,7 +54,7 @@ export type { GuideBodyProps, GuideInjected } from './tabs/guide/GuideBody.tsx' export type { ExpandButtonProps } from './shell/ExpandButton.tsx' export type { SidebarRightState, SurfaceState } from './stores.ts' export type { - ISidebarRight, SidebarRightBinding, SidebarRightOpenResourceOptions, SidebarRightOpenTabOptions, + ISidebarRight, SidebarRightOpenResourceOptions, SidebarRightOpenTabOptions, SidebarRightPlacement, SidebarRightCloseHandler, SurfaceActions, } from './service.ts' export type { @@ -115,10 +116,34 @@ export function apply(ctx: ClientContext): void { sync() return () => { unsubscribe(); views.dispose() } }, 'ui-sidebar-right: retained Session views') - const { controller, adopt, forget } = createSidebarRightController( + const layout: ILayout = ctx.layout + const { controller, adopt, forget, show, measure } = createSidebarRightController( tabs, (address, signal) => { ctx.resources.pin(address, signal) }, + { + autoFullscreen: () => layout.viewportWidth.getSnapshot() < AUTO_FULLSCREEN_BELOW, + openWithFocus: (sessionId, open) => { openWithPaneFocus(document, sessionId, open) }, + closeWithFocus: (sessionId, paneId, close) => { closeWithPaneFocus(document, sessionId, paneId, close) }, + }, ) + // The Session on screen: the selected one while the Conversation fills the + // main column. Both sources notify before React renders their change, so the + // service names the arriving Session before any component of that commit + // reads it, and its seat mints the Session's store in the same render. + ctx.effect(() => { + const sync = (): void => { + const selected = views.source.getSnapshot().find(view => view.selected) + show(layout.panelInfo.getSnapshot().activePanelId === null ? selected?.sessionId : undefined) + } + const unsubscribeViews = views.source.subscribe(sync) + const unsubscribePanel = layout.panelInfo.subscribe(sync) + sync() + return () => { + unsubscribeViews() + unsubscribePanel() + show(undefined) + } + }, 'ui-sidebar-right: on-screen Session') const disposeRegistry = ctx.reflect.provide('sidebarRightTabs', tabs) const disposeService = ctx.reflect.provide('sidebarRight', controller) // Registered first, so it tears down last: the faces outlive every seat and @@ -157,13 +182,11 @@ export function apply(ctx: ClientContext): void { } } }, } - const layout: ILayout = ctx.layout - const injected: Omit = { + const injected: Omit = { syncPresentation({ shown, track, fullscreen }) { if (shown) layout.openRightbar(track, fullscreen) else layout.closeRightbar() }, - bindService: binding => controller.bind(binding), splitPane: (paneId) => { controller.split(paneId) }, toggleFullscreen: () => { const target = controller.commandTarget(); if (target !== undefined) controller.toggleFullscreen(target) }, openTab: (kind, options) => { controller.openTab(kind, options) }, @@ -194,6 +217,7 @@ export function apply(ctx: ClientContext): void { store, inject: (sessionId): SidebarRightInjected => ({ ...injected, + measureRoom: (canSplitPane) => { measure(sessionId, canSplitPane) }, closeTab: (tabId) => { try { controller.closeIn(sessionId, tabId) } catch (error) { console.error('Sidebar tab close failed:', error) } diff --git a/packages/client/ui-sidebar-right/src/client/service.ts b/packages/client/ui-sidebar-right/src/client/service.ts index 6159e74173..5ff81e48e4 100644 --- a/packages/client/ui-sidebar-right/src/client/service.ts +++ b/packages/client/ui-sidebar-right/src/client/service.ts @@ -3,19 +3,21 @@ * * The surface is per session and its state lives in that session's store * instance, which the slot runtime mints per session and a root service cannot - * reach on its own. Two paths lead in. The mounted seat publishes its binding — - * session id, bound actions, its surface — for exactly as long as it is mounted, - * and every command on the public face goes through that binding; a command - * arriving with no seat mounted has no session to act on and fails loudly rather - * than writing into a surface nobody is drawing. `mounted` publishes that - * binding's session as an observable, so a consumer that wants to open content - * as soon as a seat is on screen subscribes to it instead of assuming one is - * bound when its own effect runs. And the plugin adopts each - * session's store instance as the runtime mints it, so the controller reaches - * any session's store by id and syncs the Tab domain from that store's commits. + * reach on its own. The plugin adopts each session's store instance as the + * runtime mints it, so the controller reaches any session's store by id and + * syncs the Tab domain from that store's commits, on screen or not. + * + * The plugin also names the Session on screen — the selected Session while the + * Conversation fills the main column — from the selection and the main panel, + * before React renders either change, and publishes it as `mounted`. Every + * command on the public face acts on that Session through its adopted store; a + * command with no Session on screen, or with one whose store the runtime has not + * minted, has nothing to act on and fails loudly rather than writing into a + * surface nobody is drawing. The seats never publish which Session they draw: + * a seat reports only the room rule its docking kit measured for its panes. * * A tab's own actions (`tabActions`) aim at the session the tab is in, not at - * the mounted one: they run through that session's adopted store, so a callback + * the on-screen one: they run through that session's adopted store, so a callback * fired after the user switched sessions still lands where its tab is, and they * do nothing for a session whose store was never minted. * @@ -27,8 +29,8 @@ * hand the store one settled intent and record the navigation in the Tab * domain. Placement is the caller's option, never a type's property. * - * The registration adopts Session stores and injects the mounted seat binding; - * callers use the service's navigation methods. + * The registration adopts Session stores, names the on-screen Session, and + * forwards each seat's room readings; callers use the service's navigation methods. */ import { sidebarTargetFromElement, type SidebarRightTarget } from './focus.ts' import { randomUUID } from '@deepseek-ai/dsh-util-crypto' @@ -61,24 +63,71 @@ interface Adoption { readonly unsubscribe: () => void } +/** The room rule's verdict for a docked pane, as a seat's docking kit last measured it: whether two working halves would fit. */ +type RoomRule = (paneId: PaneId) => boolean + +/** Plugin-owned Session state the controller reads; the plugin writes it through the factory's callbacks. */ +interface SidebarRightSessions { + /** Each Session's latest adopted store. */ + readonly adopted: ReadonlyMap + /** Each Session's room rule as its seat last reported it; a Session without one has room in every pane. */ + readonly rooms: ReadonlyMap + /** The Session the plugin names as on screen. */ + readonly onScreen: ObservableSnapshot + /** Open tab metadata across saved and adopted Sessions. */ + readonly openTabs: SidebarTabInventory['source'] +} + +/** What the controller needs from the page around it: the viewport rule and focus continuity. */ +export interface SidebarRightHost { + /** + * Whether the viewport is narrow enough that an expanded panel is presented fullscreen. + * @returns the rule's verdict for the current frame width. + */ + readonly autoFullscreen: () => boolean + /** + * Commit a page operation and focus the pane it selects. + * @param sessionId - the Session whose page is opening. + * @param open - the synchronous operation; returns the selected pane, or `undefined` when unchanged. + */ + readonly openWithFocus: (sessionId: SessionId, open: () => PaneId | undefined) => void + /** + * Commit a keyboard/menu close and retain focus on a surviving visible pane. + * @param sessionId - the Session whose page is closing. + * @param paneId - the pane whose page is closing. + * @param close - the synchronous cleanup and removal. + */ + readonly closeWithFocus: (sessionId: SessionId, paneId: PaneId, close: () => void) => void +} + /** - * Create the public controller and the plugin-private store adoption callback. + * Create the public controller and the plugin-private Session callbacks. * Adoption reconciles restored records before any seat renders, then follows commits. * @param tabs - registered tab types. * @param pin - resource retention for an occurrence's lifetime. - * @returns the controller and plugin-owned adoption and scope-removal callbacks. + * @param host - the viewport rule and focus continuity the commands use. + * @returns the controller; store adoption and scope removal; naming the on-screen Session; and recording a seat's room rule. */ -export function createSidebarRightController(tabs: SidebarRightTabRegistry, pin: PinResource): { +export function createSidebarRightController(tabs: SidebarRightTabRegistry, pin: PinResource, host: SidebarRightHost): { controller: SidebarRightController adopt: (sessionId: SessionId, store: SidebarRightSurfaceStore) => () => void forget: (sessionId: SessionId) => void + show: (sessionId: SessionId | undefined) => void + measure: (sessionId: SessionId, canSplitPane: RoomRule) => void } { const adopted = new Map() + const rooms = new Map() + const onScreen = createSnapshotStore(undefined) const inventory = new SidebarTabInventory() - const controller = new SidebarRightController(tabs, pin, adopted, inventory.source) + const controller = new SidebarRightController(tabs, pin, host, { adopted, rooms, onScreen, openTabs: inventory.source }) return { controller, - forget: (sessionId) => { inventory.remove(sessionId) }, + forget: (sessionId) => { + inventory.remove(sessionId) + rooms.delete(sessionId) + }, + show: (sessionId) => { if (onScreen.getSnapshot() !== sessionId) onScreen.set(sessionId) }, + measure: (sessionId, canSplitPane) => { rooms.set(sessionId, canSplitPane) }, adopt(sessionId, store) { adopted.get(sessionId)?.unsubscribe() const sync = (): void => { @@ -97,31 +146,6 @@ export function createSidebarRightController(tabs: SidebarRightTabRegistry, pin: } } -/** Everything a command needs, as the mounted seat sees it. */ -export interface SidebarRightBinding { - /** The session the mounted seat is drawing. */ - readonly sessionId: SessionId - /** The seat's store's bound actions; every action names the session it acts on. */ - readonly actions: SurfaceActions - /** - * The seat's store surfaces as last committed, keyed by session id; the - * mounted session's is `surfaces[sessionId]`, absent before the seat's first - * open. The runtime mints one store per session, so this holds that session. - */ - readonly surfaces: Readonly> - /** - * The room rule's verdict for a docked pane, as the kit last measured it: - * whether two working halves would fit. Unmeasured panes fit. - */ - readonly canSplitPane: (paneId: PaneId) => boolean - /** Commit a keyboard/menu close and retain focus on a surviving visible pane. */ - readonly closeWithFocus: (paneId: PaneId, close: () => void) => void - /** Commit a page operation and focus the pane it selects. */ - readonly openWithFocus: (open: () => PaneId | undefined) => void - /** Narrow viewports present an expanded panel fullscreen. */ - readonly autoFullscreen?: boolean -} - /** Where an open lands; every field is optional and the defaults are the common case. */ export interface SidebarRightPlacement { /** Land a new tab in this pane instead of the active docked one. */ @@ -160,15 +184,13 @@ export type SidebarRightCloseHandler = (sessionId: SessionId, tab: TabRecord) => /** The outward right-Sidebar face (`ctx.sidebarRight`). */ export interface ISidebarRight { /** - * The session whose seat is mounted, or `undefined` while no seat is on - * screen (a global panel is active, or no session is selected). Moves only - * when a seat binds or releases; a seat republishing its binding for the - * same session is silent. A component that opens content from its own - * effect reads it through a bound hook, acts once it is defined, reads - * `getSnapshot()` again when it acts, and retries when the value changes: - * the frame mounts the Conversation column ahead of the seat, so in a - * commit that switches sessions the departing seat releases before the - * Conversation's effects run and the arriving seat binds after them. + * The Session on screen, or `undefined` while none is (a global panel fills + * the main column, or no Session is selected). It changes before React + * renders the selection or panel change that causes it, so every component + * rendered in that commit reads the arriving Session, and the commands act on + * it from that commit's effects: its seat mints its store in the same render. + * Moves only when the on-screen Session changes; the Session's own store + * commits are silent. */ readonly mounted: ObservableSnapshot /** @@ -192,13 +214,13 @@ export interface ISidebarRight { */ openTab(kind: K, options?: SidebarRightOpenTabOptions): void /** - * Close one tab of the mounted session; the sole docked guide remains open. + * Close one tab of the on-screen Session; the sole docked guide remains open. * @param tabId - the tab to close. */ close(tabId: TabId): void /** * The active tab of the active pane. - * @returns the record, or `undefined` when no seat is mounted. + * @returns the record, or `undefined` with no Session on screen. */ active(): TabRecord | undefined /** @@ -239,10 +261,10 @@ export interface ISidebarRight { export class SidebarRightController implements ISidebarRight { /** Open tab metadata across saved and adopted Sessions, independent of visible seats. */ readonly openTabs: SidebarTabInventory['source'] - private readonly mountedSession = createSnapshotStore(undefined) - /** The mounted seat's session; see {@link ISidebarRight.mounted}. */ - readonly mounted: ObservableSnapshot = this.mountedSession - private binding: SidebarRightBinding | undefined + /** The on-screen Session; see {@link ISidebarRight.mounted}. */ + readonly mounted: ObservableSnapshot + private readonly adopted: ReadonlyMap + private readonly rooms: ReadonlyMap private readonly closeHandlers = new Map() /** @@ -266,16 +288,19 @@ export class SidebarRightController implements ISidebarRight { /** * @param tabs - the tab-type registry consulted to claim an address. * @param pin - `ctx.resources.pin`, which the Tab domain holds addresses with. - * @param adopted - plugin-owned session stores used by occurrence actions. - * @param openTabs - plugin-owned metadata source across saved and adopted layouts. + * @param host - the viewport rule and focus continuity the commands use. + * @param sessions - plugin-owned adopted stores, room rules, on-screen Session, and open tab metadata. */ constructor( private readonly tabs: SidebarRightTabRegistry, pin: PinResource, - private readonly adopted = new Map(), - openTabs: SidebarTabInventory['source'] = new SidebarTabInventory().source, + private readonly host: SidebarRightHost, + sessions: SidebarRightSessions, ) { - this.openTabs = openTabs + this.adopted = sessions.adopted + this.rooms = sessions.rooms + this.mounted = sessions.onScreen + this.openTabs = sessions.openTabs this.tabDomain = new TabDomain(this, pin) } @@ -288,32 +313,6 @@ export class SidebarRightController implements ISidebarRight { return Object.values(this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId]?.layout.tabs ?? {}) } - /** - * Adopt the mounted seat's binding, replacing any previous one. - * - * Called from the seat after each commit while it is active, and released - * when it leaves. - * @param binding - the mounted seat's session, actions, and the store's surfaces. - * @returns a release callback that clears exactly this binding. - */ - bind(binding: SidebarRightBinding): () => void { - this.binding = binding - this.publishMounted() - return () => { - // A newer seat may already have taken over; only the binding that is - // still ours may be cleared. - if (this.binding !== binding) return - this.binding = undefined - this.publishMounted() - } - } - - /** Publish the mounted session only when it changes; a republished binding for the same session is silent. */ - private publishMounted(): void { - const next = this.binding?.sessionId - if (this.mountedSession.getSnapshot() !== next) this.mountedSession.set(next) - } - /** * Open a resource: claim it, place it, reveal the column, record the navigation. * @param address - a `dsh-resource:///…` address. @@ -417,7 +416,6 @@ export class SidebarRightController implements ISidebarRight { params: SidebarRightNavigationParams, ): void { const surface = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId] - ?? (this.binding?.sessionId === sessionId ? this.binding.surfaces[sessionId] : undefined) const targetPane = surface === undefined ? undefined : placement.paneId ?? activeDockPaneId(surface.layout) const target = targetPane === undefined ? undefined : surface?.layout.nodes[targetPane] const preferNewPane = placement.preferNewPane === true @@ -428,8 +426,8 @@ export class SidebarRightController implements ISidebarRight { && target.tabs.length > 0 && canSplit(surface.layout) && dockPaneIds(surface.layout).length < 2 - && this.binding?.sessionId === sessionId - && this.binding.canSplitPane(target.id) + && this.mounted.getSnapshot() === sessionId + && this.canSplitPane(sessionId, target.id) const commit = (): void => { actions.openContent(sessionId, { kind: claim.kind, contentId: claim.contentId, @@ -448,18 +446,16 @@ export class SidebarRightController implements ISidebarRight { } /** - * Close one tab of the mounted session; the sole docked guide remains open. + * Close one tab of the on-screen Session; the sole docked guide remains open. * @param tabId - the tab to close. */ close(tabId: TabId): void { - const { sessionId, actions } = this.require() - if (this.adopted.has(sessionId)) { this.closeIn(sessionId, tabId); return } - actions.closeTab(sessionId, tabId) + this.closeIn(this.require().sessionId, tabId) } /** * The active tab of the active pane. - * @returns the record, or `undefined` with no mounted surface. + * @returns the record, or `undefined` without an on-screen surface. */ active(): TabRecord | undefined { const layout = this.mountedSurface()?.layout @@ -470,7 +466,7 @@ export class SidebarRightController implements ISidebarRight { /** * Whether the column is currently showing its panel. - * @returns `true` while expanded; `false` while collapsed or with no mounted surface. + * @returns `true` while expanded; `false` while collapsed or without an on-screen surface. */ isExpanded(): boolean { return this.mountedSurface()?.layout.expanded ?? false @@ -478,8 +474,8 @@ export class SidebarRightController implements ISidebarRight { /** Collapse the column, or expand it and focus its active dock pane after rendering. */ toggleExpanded(): void { - const { sessionId, actions, openWithFocus } = this.require() - openWithFocus(() => { + const { sessionId, actions } = this.require() + this.host.openWithFocus(sessionId, () => { actions.toggleExpanded(sessionId) const layout = this.mountedSurface()?.layout return layout?.expanded ? activeDockPaneId(layout) : undefined @@ -502,28 +498,27 @@ export class SidebarRightController implements ISidebarRight { * @returns current focused page identity, or undefined outside a visible sidebar page. */ focusedTarget(element: Element | null = document.activeElement): SidebarRightTarget | undefined { - const binding = this.binding - const surface = this.mountedSurface() - return binding === undefined || surface === undefined ? undefined - : sidebarTargetFromElement(element, binding.sessionId, surface.layout, - tabId => this.tabDomain.occurrence(binding.sessionId, { id: tabId })) + const screen = this.screen() + return screen === undefined ? undefined + : sidebarTargetFromElement(element, screen.sessionId, screen.surface.layout, + tabId => this.tabDomain.occurrence(screen.sessionId, { id: tabId })) } /** - * Choose a focused sidebar pane, or the mounted Session's active dock pane for an outside open. + * Choose a focused sidebar pane, or the on-screen Session's active dock pane for an outside open. * @param element - live command input target; stale sidebar markup never falls back to another pane. - * @returns captured target, or undefined without a mounted Session. + * @returns captured target, or undefined without an on-screen surface. */ commandTarget(element: Element | null = document.activeElement): SidebarRightTarget | undefined { const focused = this.focusedTarget(element) if (focused !== undefined || element?.closest('[data-sidebar-right-session]')) return focused - const binding = this.binding - const layout = this.mountedSurface()?.layout - if (binding === undefined || layout === undefined) return undefined + const screen = this.screen() + if (screen === undefined) return undefined + const { sessionId, surface: { layout } } = screen const pane = getPane(layout, activeDockPaneId(layout)) const tabId = pane.activeTabId - const held = tabId === undefined ? undefined : this.tabDomain.occurrence(binding.sessionId, { id: tabId }) - return { sessionId: binding.sessionId, paneId: pane.id, host: pane.host, tabId, + const held = tabId === undefined ? undefined : this.tabDomain.occurrence(sessionId, { id: tabId }) + return { sessionId, paneId: pane.id, host: pane.host, tabId, occurrence: held, navigationRevision: held?.navigation.getSnapshot().revision } } @@ -534,10 +529,11 @@ export class SidebarRightController implements ISidebarRight { */ openTabFromTarget(kind: string, target: SidebarRightTarget): void { if (!this.isTargetCurrent(target)) return - this.require().openWithFocus(() => { + const { sessionId, actions } = this.require() + this.host.openWithFocus(sessionId, () => { const tab = target.tabId === undefined ? undefined : this.mountedSurface()?.layout.tabs[target.tabId] if (target.host === 'float' && tab?.kind === kind && this.tabs.get(kind)?.multiple !== true) { - this.require().actions.setExpanded(target.sessionId, true) + actions.setExpanded(sessionId, true) this.focus(tab.id) } else { this.openTab(kind, { @@ -569,10 +565,10 @@ export class SidebarRightController implements ISidebarRight { const surface = this.mountedSurface() if (surface === undefined || target.tabId === undefined) return 'unavailable' const tabId = target.tabId - const binding = this.require() - binding.closeWithFocus(target.paneId, () => { + const { sessionId, actions } = this.require() + this.host.closeWithFocus(sessionId, target.paneId, () => { if (canCloseTab(surface, tabId)) this.close(tabId) - else binding.actions.setExpanded(target.sessionId, false) + else actions.setExpanded(sessionId, false) }) return 'closed' } @@ -580,10 +576,10 @@ export class SidebarRightController implements ISidebarRight { /** * Check a captured target before acting, including reopened records and intervening navigation. * @param target - identity captured while resolving the input. - * @returns whether the mounted Session, pane, tab lifetime and navigation still match. + * @returns whether the on-screen Session, pane, tab lifetime and navigation still match. */ isTargetCurrent(target: SidebarRightTarget): boolean { - if (this.binding?.sessionId !== target.sessionId) return false + if (this.mounted.getSnapshot() !== target.sessionId) return false const layout = this.mountedSurface()?.layout const pane = layout?.nodes[target.paneId] if (pane?.kind !== 'pane' || pane.host !== target.host) return false @@ -607,7 +603,7 @@ export class SidebarRightController implements ISidebarRight { if (!layout.expanded) return 'collapsed' if (getPane(layout, target.paneId).tabs.length === 0) return 'empty' if (!canSplit(layout) || dockPaneIds(layout).length >= 2) return 'budget' - if (!this.require().canSplitPane(target.paneId)) return 'width' + if (!this.canSplitPane(target.sessionId, target.paneId)) return 'width' return undefined } @@ -617,9 +613,10 @@ export class SidebarRightController implements ISidebarRight { */ toggleFullscreen(target: SidebarRightTarget): void { if (!this.isTargetCurrent(target) || target.host === 'float' || !this.isExpanded()) return - const { sessionId, actions, autoFullscreen } = this.require() - const fullscreen = autoFullscreen === true || this.mountedSurface()?.layout.mode === 'fullscreen' - if (fullscreen && autoFullscreen === true) actions.setExpanded(sessionId, false) + const { sessionId, actions } = this.require() + const autoFullscreen = this.host.autoFullscreen() + const fullscreen = autoFullscreen || this.mountedSurface()?.layout.mode === 'fullscreen' + if (fullscreen && autoFullscreen) actions.setExpanded(sessionId, false) actions.setMode(sessionId, fullscreen ? 'push' : 'fullscreen') } @@ -629,15 +626,15 @@ export class SidebarRightController implements ISidebarRight { * @returns the new pane's id, or `undefined` when nothing was split. */ split(paneId?: PaneId): PaneId | undefined { - const { sessionId, actions, canSplitPane } = this.require() + const { sessionId, actions } = this.require() const layout = this.mountedSurface()?.layout if (layout === undefined) return undefined const target = paneId ?? activeDockPaneId(layout) const node = layout.nodes[target] if (node === undefined || node.kind !== 'pane' || node.host !== 'dock') return undefined - if (!canSplit(layout) || dockPaneIds(layout).length >= 2 || !canSplitPane(target)) return undefined + if (!canSplit(layout) || dockPaneIds(layout).length >= 2 || !this.canSplitPane(sessionId, target)) return undefined let created: PaneId | undefined - this.require().openWithFocus(() => { + this.host.openWithFocus(sessionId, () => { actions.splitPane(sessionId, target, (id) => { created = id }) return created }) @@ -669,7 +666,7 @@ export class SidebarRightController implements ISidebarRight { } /** - * Step the mounted session's surface back one intent. + * Step the on-screen Session's surface back one intent. * * @internal Not part of the product: the sequence is an architectural fact * with no user-facing control yet. Kept reachable for tests. @@ -680,7 +677,7 @@ export class SidebarRightController implements ISidebarRight { } /** - * Step the mounted session's surface forward one intent. + * Step the on-screen Session's surface forward one intent. * * @internal See `_undo`. */ @@ -689,11 +686,25 @@ export class SidebarRightController implements ISidebarRight { actions.redo(sessionId) } - /** The mounted session's surface; `undefined` without a seat or before its first open. */ + /** + * The on-screen Session and its committed surface; `undefined` with no Session + * on screen, before the runtime mints its store, or before its first open. + */ + private screen(): { readonly sessionId: SessionId; readonly surface: SurfaceState } | undefined { + const sessionId = this.mounted.getSnapshot() + if (sessionId === undefined) return undefined + const surface = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId] + return surface === undefined ? undefined : { sessionId, surface } + } + + /** The on-screen Session's surface; see {@link SidebarRightController.screen}. */ private mountedSurface(): SurfaceState | undefined { - const { binding } = this - return binding === undefined ? undefined - : this.adopted.get(binding.sessionId)?.store.getSnapshot().bySession[binding.sessionId] ?? binding.surfaces[binding.sessionId] + return this.screen()?.surface + } + + /** Whether a Session's docked pane has room for two working halves, as its seat last measured; unmeasured panes do. */ + private canSplitPane(sessionId: SessionId, paneId: PaneId): boolean { + return this.rooms.get(sessionId)?.(paneId) ?? true } /** @@ -705,13 +716,15 @@ export class SidebarRightController implements ISidebarRight { return this.adopted.get(sessionId)?.store.actions } - private require(): SidebarRightBinding { + private require(): { readonly sessionId: SessionId; readonly actions: SurfaceActions } { // Reads answer for the no-session case (there is nothing expanded), but a // write has no session to write to. Callers are UI gestures and tool // results, both of which belong to a session that is on screen. - if (this.binding === undefined) { + const sessionId = this.mounted.getSnapshot() + const actions = sessionId === undefined ? undefined : this.actionsFor(sessionId) + if (sessionId === undefined || actions === undefined) { throw new Error('sidebarRight: no session surface is mounted') } - return this.binding + return { sessionId, actions } } } diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx index 1c6433dc0c..62a4cab3e6 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx @@ -49,7 +49,6 @@ import type { TabOccurrence } from '../tab-domain.ts' import type { SidebarRightTabNavigation } from '../contract/slots.ts' import type { TabHookContext } from '../tab-info.ts' import css from './SidebarRight.module.css' -import { closeWithPaneFocus, openWithPaneFocus } from './close-focus.ts' /** The store share the seat receives. */ type Store = PropsStore> @@ -78,29 +77,13 @@ export interface SidebarRightInjected { */ readonly syncPresentation: (presentation: SidebarRightPresentation) => void /** - * Publish this seat's session, actions, and the store's surfaces to `ctx.sidebarRight`. + * Record this seat's room rule for `ctx.sidebarRight`'s splits, replacing its previous one. * - * The service is root-scoped and cannot read a per-entry store, so the only - * honest source is the mounted seat. The seat calls it after each commit - * while it is active, each call replacing the previous binding, and calls - * the latest release when it leaves the screen; the service publishes the - * bound session through `ctx.sidebarRight.mounted`. - * @param binding - what a command needs to act on this session, and what a tab's own action needs to act on its. - * @returns a release callback that clears this binding while it is still the latest. + * The docking kit measures its panes after it renders; the seat forwards each + * reading, and the service applies it when it splits this seat's Session. + * @param canSplitPane - whether two working halves of a docked pane would fit; unmeasured panes fit. */ - readonly bindService: (binding: { - sessionId: SessionId - actions: Store['actions'] - /** Every session's surface as last committed; the mounted one is `surfaces[sessionId]`. */ - surfaces: Readonly> - /** The room rule's verdict for a docked pane, as the kit last measured it. */ - canSplitPane: (paneId: PaneId) => boolean - /** Commit a keyboard/menu close and retain focus on a surviving visible pane. */ - closeWithFocus: (paneId: PaneId, close: () => void) => void - /** Commit a page operation and focus the pane it selects. */ - openWithFocus: (open: () => PaneId | undefined) => void - autoFullscreen?: boolean - }) => () => void + readonly measureRoom: (canSplitPane: (paneId: PaneId) => boolean) => void /** * The navigation face's `openTab`, for the strip's add control: a new tab is * the guide opened by kind, through the same path as every other open. @@ -353,31 +336,30 @@ function SidebarPanel(panel: PanelProps & { width: number; panelRef: RefObject entries) - const surfaces = useStore(state => state.bySession) - const surface = surfaces[sessionId] + const surface = useStore(state => state.bySession[sessionId]) const shown = active && surface !== undefined && surface.layout.expanded - const autoFullscreen = viewportWidth < 768 + const autoFullscreen = viewportWidth < AUTO_FULLSCREEN_BELOW const fullscreen = autoFullscreen || surface?.layout.mode === 'fullscreen' const panelRef = useRef(null) - // The kit's room-rule readings, kept in a ref: the service reads them at - // call time through the binding, and a reading never re-renders anything. - const room = useRef>(new Map()) - const reportRoom = useCallback((fits: ReadonlyMap): void => { room.current = fits }, []) + // A reading never re-renders anything: the service applies it when it splits. + const reportRoom = useCallback((fits: ReadonlyMap): void => { + measureRoom(paneId => fits.get(paneId)?.row !== false) + }, [measureRoom]) const track = shown && !autoFullscreen useEffect(() => { @@ -421,22 +403,6 @@ export function RightbarSeat({ ? () => { syncPresentation({ shown: false, track: false, fullscreen: false }) } : undefined, [syncPresentation, active]) - // Republished on every committed change: the service's readers answer from the - // last commit, and its commands act on the session actually on screen. A - // republish replaces the previous binding without releasing it, because a - // release between them would leave the service unbound while the rest of - // that commit's effects run; the latest binding is released only when the - // seat leaves the screen. - const release = useRef<(() => void) | undefined>(undefined) - useEffect(() => { - if (active) { - release.current = bindService({ sessionId, actions, surfaces, autoFullscreen, - closeWithFocus: (paneId, close) => { closeWithPaneFocus(document, sessionId, paneId, close) }, - openWithFocus: (open) => { openWithPaneFocus(document, sessionId, open) }, - canSplitPane: paneId => room.current.get(paneId)?.row !== false }) - } - }, [bindService, sessionId, actions, surfaces, autoFullscreen, active]) - useEffect(() => active ? () => { release.current?.() } : undefined, [active]) // The Tab domain is not synced here: the controller adopted this session's // store as the runtime minted it and reconciles on the store's own commits, // on screen or not. diff --git a/packages/client/ui-sidebar-right/tests/apply.client.spec.ts b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts index 42e27709bd..dd364a9c24 100644 --- a/packages/client/ui-sidebar-right/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts @@ -5,7 +5,7 @@ * means what those faces do; the slot, locale, frame, and resource faces are * recorders, because what matters here is what was handed to them — two seats * over one store, the guide's body under its own id, the frame reports, the - * service binding — and that every registration is gone after dispose, which + * on-screen Session — and that every registration is gone after dispose, which * is what makes a reload safe. The seats' components have their own specs. */ import { describe, expect, it, onTestFinished, vi } from 'vitest' @@ -63,7 +63,12 @@ async function boot(shortcuts: Partial = {}) { return () => { dictionaries.delete(ns) } }), } - const layout = { openRightbar: vi.fn(), closeRightbar: vi.fn() } + const layout = { + openRightbar: vi.fn(), closeRightbar: vi.fn(), + panelInfo: createSnapshotStore<{ activePanelId: string | null }>({ activePanelId: null }), + viewportWidth: createSnapshotStore(1440), + } + const current = createSnapshotStore<{ key: SessionId | undefined }>({ key: undefined }) const resources = { pin: vi.fn<(address: string, signal: AbortSignal) => void>() } ctx.provide('slots', slots as never) ctx.provide('locale', locale as never) @@ -71,8 +76,8 @@ async function boot(shortcuts: Partial = {}) { catalog: { getSnapshot: () => SHORTCUT_CATALOG, subscribe: () => () => {} }, ...shortcuts } as never) ctx.provide('layout', layout as never) ctx.provide('resources', resources as never) - ctx.provide('sessions', { retain: vi.fn() } as never) - ctx.provide('uiSession', { adapter: { current: createSnapshotStore({ key: undefined }) } } as never) + ctx.provide('sessions', { retain: vi.fn(() => ({ ready: Promise.resolve(), release: vi.fn() })) } as never) + ctx.provide('uiSession', { adapter: { current } } as never) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() const seat = (name: string): Recorded => { @@ -84,7 +89,7 @@ async function boot(shortcuts: Partial = {}) { if (entry.inject === undefined) throw new Error(`expected ${entry.name} to inject`) return entry.inject(SESSION) } - return { ctx, registered, dictionaries, layout, resources, fiber, seat, injectedOf } + return { ctx, registered, dictionaries, layout, current, resources, fiber, seat, injectedOf } } describe('ui-sidebar-right apply', () => { @@ -143,8 +148,21 @@ describe('ui-sidebar-right apply', () => { expect(seat('conversation.session.header.corner').store).toBe(seat('rightbar.session').store) }) - it('hands the panel seat the frame report, the service binding, the opens, the observable registry, and the Tab domain', async () => { - const { ctx, layout, resources, seat, injectedOf } = await boot() + it('names the selected Session as on screen while the Conversation fills the main column', async () => { + const { ctx, layout, current } = await boot() + expect(ctx.sidebarRight.mounted.getSnapshot()).toBeUndefined() + current.set({ key: SESSION }) + expect(ctx.sidebarRight.mounted.getSnapshot()).toBe(SESSION) + layout.panelInfo.set({ activePanelId: 'plugins' }) + expect(ctx.sidebarRight.mounted.getSnapshot()).toBeUndefined() + layout.panelInfo.set({ activePanelId: null }) + expect(ctx.sidebarRight.mounted.getSnapshot()).toBe(SESSION) + current.set({ key: undefined }) + expect(ctx.sidebarRight.mounted.getSnapshot()).toBeUndefined() + }) + + it('hands the panel seat the frame report, the room rule, the opens, the observable registry, and the Tab domain', async () => { + const { ctx, layout, current, resources, seat, injectedOf } = await boot() const injected = injectedOf(seat('rightbar.session')) as SidebarRightInjected // The frame learns the composition of expanded and presentation, nothing else. injected.syncPresentation({ shown: true, track: true, fullscreen: false }) @@ -162,31 +180,35 @@ describe('ui-sidebar-right apply', () => { ctx.sidebarRightTabs.register({ id: 'spec/text', kind: 'text', patterns: ['dsh-resource://file/**'], title: () => 'text' }) expect(seen).toHaveBeenCalledOnce() unsubscribe() - // The binding makes the service act on this seat's session; the seat's - // store instance is minted here from the handle the registration declared. + // The runtime mints each Session's store from the handle the registration + // declared; selecting the Session puts it on screen, and the service acts on + // its adopted store. const handle = seat('rightbar.session').store as ReturnType - const instance = handle.create() - instance.clearPersisted() - vi.stubGlobal('document', { activeElement: null }) + handle.create().clearPersisted() + const instance = handle.create(SESSION) + vi.stubGlobal('document', { activeElement: null, querySelectorAll: () => [] }) onTestFinished(() => { vi.unstubAllGlobals() }) injected.toggleFullscreen() expect(injectedOf(seat('conversation.session.header.corner'))).toHaveProperty('hooks.shortcuts') - const release = injected.bindService({ - sessionId: SESSION, actions: instance.actions, surfaces: {}, - closeWithFocus: (_paneId, close) => { close() }, - openWithFocus: (open) => { open() }, - canSplitPane: () => true, - }) + current.set({ key: SESSION }) injected.openTab('guide', { revealIfOpened: false }) const surface = instance.getSnapshot().bySession[SESSION] expect(surface?.layout.expanded).toBe(true) expect(Object.values(surface?.layout.tabs ?? {}).map(tab => tab.kind)).toEqual(['guide']) // Holding a record pins its address through the resource model. if (surface === undefined) throw new Error('expected a surface') - ctx.sidebarRight.tabDomain.sync(SESSION, surface.layout) expect(resources.pin).toHaveBeenCalledWith('sidebar://guide', expect.any(AbortSignal)) + // The seat reports the room rule its kit measured; a narrow pane is not split. + const panes = () => Object.values(instance.getSnapshot().bySession[SESSION]?.layout.nodes ?? {}).filter(node => node.kind === 'pane') + injected.measureRoom(() => false) injected.splitPane(surface.layout.activePaneId) - release() + expect(panes()).toHaveLength(1) + injected.measureRoom(() => true) + injected.splitPane(surface.layout.activePaneId) + expect(panes()).toHaveLength(2) + injected.toggleFullscreen() + // A global panel takes the Conversation's place: no Session is on screen. + layout.panelInfo.set({ activePanelId: 'plugins' }) expect(() => { ctx.sidebarRight.toggleExpanded() }).toThrow('no session surface is mounted') }) @@ -274,17 +296,12 @@ describe('ui-sidebar-right apply', () => { }) it('takes every registration and both faces back when disposed, aborting the open records, so a reload registers again', async () => { - const { ctx, registered, dictionaries, fiber, seat, injectedOf } = await boot() + const { ctx, registered, dictionaries, current, fiber, seat, injectedOf } = await boot() const injected = injectedOf(seat('rightbar.session')) as SidebarRightInjected const handle = seat('rightbar.session').store as ReturnType // Minted under the session key, so the instance is adopted and the teardown releases it. const instance = handle.create(SESSION) - injected.bindService({ - sessionId: SESSION, actions: instance.actions, surfaces: {}, - closeWithFocus: (_paneId, close) => { close() }, - openWithFocus: (open) => { open() }, - canSplitPane: () => true, - }) + current.set({ key: SESSION }) injected.openTab('guide') const surface = instance.getSnapshot().bySession[SESSION] const guide = Object.values(surface?.layout.tabs ?? {})[0] diff --git a/packages/client/ui-sidebar-right/tests/focus.client.spec.ts b/packages/client/ui-sidebar-right/tests/focus.client.spec.ts index fd88240f6e..b949dd5e53 100644 --- a/packages/client/ui-sidebar-right/tests/focus.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/focus.client.spec.ts @@ -26,17 +26,22 @@ function harness() { const ctx = new Context() const tabs = new SidebarRightTabRegistry(ctx) for (const kind of ['guide', 'files', 'terminal']) tabs.register({ id: kind, kind, title: () => kind, multiple: kind === 'terminal' }) - const { controller, adopt } = createSidebarRightController(tabs, vi.fn()) + const room = { allowed: true, autoFullscreen: false } + const { controller, adopt, show: showSession, measure } = createSidebarRightController(tabs, vi.fn(), { + autoFullscreen: () => room.autoFullscreen, + openWithFocus: (_sessionId, open) => { open() }, + closeWithFocus: (_sessionId, _paneId, close) => { close() }, + }) const store = createSidebarRightStore(() => ({ kind: 'guide', title: 'guide' })).create() releases.push(adopt(SESSION, store), () => { controller.tabDomain.dispose() }) store.actions.setExpanded(SESSION, true) - const room = { allowed: true, autoFullscreen: false } - const bind = (sessionId = SESSION) => controller.bind({ sessionId, actions: store.actions, - surfaces: store.getSnapshot().bySession, - closeWithFocus: (_paneId, close) => { close() }, - openWithFocus: (open) => { open() }, - canSplitPane: () => room.allowed, autoFullscreen: room.autoFullscreen }) - releases.push(bind()) + /** Put a Session on screen the way the plugin does, with the room rule its seat reports. */ + const show = (sessionId = SESSION) => { + showSession(sessionId) + measure(sessionId, () => room.allowed) + return () => { if (controller.mounted.getSnapshot() === sessionId) showSession(undefined) } + } + releases.push(show()) const layout = () => store.getSnapshot().bySession[SESSION]!.layout const owner = document.createElement('section') owner.dataset.sidebarRightSession = SESSION @@ -57,7 +62,7 @@ function harness() { parent.append(element) return element } - return { controller, store, layout, paneElement, tabElement, room, bind } + return { controller, store, layout, paneElement, tabElement, room, show } } describe('sidebar focus targets', () => { @@ -121,10 +126,10 @@ describe('sidebar focus targets', () => { expect(h.controller.focusedTarget(chip)).toBeUndefined() expect(h.controller.commandTarget(chip)).toBeUndefined() h.store.actions.setExpanded(SESSION, true) - releases.push(h.bind('another-session' as SessionId)) + releases.push(h.show('another-session' as SessionId)) expect(h.controller.isTargetCurrent(target)).toBe(false) expect(h.controller.focusedTarget(chip)).toBeUndefined() - releases.push(h.bind()) + releases.push(h.show()) h.controller.float(files) expect(h.controller.isTargetCurrent(target)).toBe(false) h.controller.dock(findTabPane(h.layout(), files).id) @@ -190,7 +195,7 @@ describe('sidebar focus targets', () => { h.controller.toggleFullscreen(floated) expect(h.layout().mode).toBe('push') h.room.autoFullscreen = true - releases.push(h.bind()) + releases.push(h.show()) h.controller.toggleFullscreen(h.controller.commandTarget(null)!) expect(h.layout().expanded).toBe(false) }) @@ -233,7 +238,7 @@ describe('sidebar focus targets', () => { expect(findTabPane(h.layout(), floatingFile).id).toBe(floating.paneId) expect(h.layout().expanded).toBe(true) expect(h.controller.splitBlock(floating)).toBe('float') - releases.push(h.bind('missing-session' as SessionId)) + releases.push(h.show('missing-session' as SessionId)) expect(h.controller.commandTarget(null)).toBeUndefined() expect(h.controller.splitBlock(floating)).toBe('stale') }) @@ -462,7 +467,7 @@ describe('sidebar keyboard commands', () => { const pendingSplit = split.resolve(context) const pendingFullscreen = fullscreen.resolve(context) const pendingToggle = toggle.resolve(context) - releases.push(h.bind('missing-session' as SessionId)) + releases.push(h.show('missing-session' as SessionId)) for (const pending of [pendingSplit, pendingFullscreen, pendingToggle]) { expect(pending.status).toBe('handled') if (pending.status === 'handled') pending.run() @@ -470,7 +475,7 @@ describe('sidebar keyboard commands', () => { expect(h.layout().mode).toBe('push') expect(h.layout().expanded).toBe(true) expect(toggle.resolve(context).status).toBe('blocked') - releases.push(h.bind()) + releases.push(h.show()) const splitNow = split.resolve(context) if (splitNow.status === 'handled') splitNow.run() expect(h.controller.splitBlock(h.controller.commandTarget(null)!)).toBe('budget') diff --git a/packages/client/ui-sidebar-right/tests/persistence.client.spec.ts b/packages/client/ui-sidebar-right/tests/persistence.client.spec.ts index 7a4678e1a9..4b6aea9bc9 100644 --- a/packages/client/ui-sidebar-right/tests/persistence.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/persistence.client.spec.ts @@ -63,7 +63,11 @@ it('pins restored records during adoption before the first render or store mutat first.actions.openContent(sessionId, { kind: 'text', contentId: address, title: 'a' }, (id) => { file = id }) const ctx = new Context() const pin = vi.fn() - const { controller, adopt } = createSidebarRightController(new SidebarRightTabRegistry(ctx), pin) + const { controller, adopt } = createSidebarRightController(new SidebarRightTabRegistry(ctx), pin, { + autoFullscreen: () => false, + openWithFocus: (_sessionId, open) => { open() }, + closeWithFocus: (_sessionId, _paneId, close) => { close() }, + }) expect(controller.tabsIn(sessionId)).toEqual([]) const restored = createSidebarRightStore(seed).create(sessionId) const release = adopt(sessionId, restored) diff --git a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx index b407ac5c66..2819bfbc63 100644 --- a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, fireEvent } from '@testing-library/react' import { useEffect, useState, useSyncExternalStore } from 'react' -import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { SlotTestRuntime, type SlotView } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ShortcutCatalogEntry, ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client' @@ -71,7 +71,9 @@ function transition(property = 'transform') { async function mountSeat(viewportWidth = 1440, canShow = true, entryCount = 0, opener = false, keepMounted = false) { const runtime = await SlotTestRuntime.create() runtimes.push(runtime) - const frame = { openRightbar: vi.fn(), closeRightbar: vi.fn() } + const frame = { + openRightbar: vi.fn(), closeRightbar: vi.fn(), panelInfo: runtime.panelInfo, viewportWidth: createSnapshotStore(viewportWidth), + } const pin = vi.fn<(address: string, signal: AbortSignal) => void>() runtime.ctx.provide('layout', frame as never) runtime.ctx.provide('resources', { pin } as never) @@ -110,10 +112,12 @@ async function mountSeat(viewportWidth = 1440, canShow = true, entryCount = 0, o return armed && visible ? : null } let arm = (_armed: boolean): void => { throw new Error('mountSeat(opener = true) renders the opener') } + let openerView: SlotView<'sidebar-right.test.opener'> | undefined if (opener) { await act(async () => { runtime.slots.register({ name: 'sidebar-right.test.opener' }, Opener) }) - const openerView = runtime.renderSlot('sidebar-right.test.opener', { armed: false }, { session: reference }) - arm = (armed) => { openerView.update({ armed }) } + const view = runtime.renderSlot('sidebar-right.test.opener', { armed: false }, { session: reference }) + openerView = view + arm = (armed) => { view.update({ armed }) } } const bodies = new Map() const titles = new Map() @@ -162,6 +166,7 @@ async function mountSeat(viewportWidth = 1440, canShow = true, entryCount = 0, o return { runtime, feature, controller, instance, actions: instance.actions, layout, open, selectSession, frame, pin, bodies, titles, hooks, view, arm, opened, registerPage, catalog, + openerView: () => openerView!, } } @@ -321,19 +326,20 @@ describe('RightbarSeat presentation', () => { expect(h.layout()).toBe(retained) }) - it('publishes the mounted seat so a Conversation opener acts in the commit that returns from a global panel', async () => { + it('names the on-screen Session before a Conversation opener acts in the commit that returns from a global panel', async () => { const h = await mountSeat(1440, true, 0, true) const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) expect(h.controller.mounted.getSnapshot()).toBe(SESSION) - // Armed while the seat is already bound: the ordinary case. + // Armed while the Session is already on screen: the ordinary case. act(() => { h.arm(true) }) expect(h.opened).toHaveLength(1) expect(Object.values(h.layout().tabs).map(tab => tab.contentId)).toEqual(h.opened) - // A global panel hides the Sidebar and releases its public navigation binding. + // A global panel takes the Conversation's place and hides the Sidebar. act(() => { h.runtime.panelInfo.set({ activePanelId: 'other-panel' as MainPanelId }) }) expect(element(h.view.container, '[data-sidebar-right-session]').hidden).toBe(true) expect(h.controller.mounted.getSnapshot()).toBeUndefined() - // The opener waits until the foreground seat republishes its navigation binding. + // Returning names the Session before the Conversation renders, so the + // opener's effect in that commit opens into the Session's store. act(() => { h.runtime.panelInfo.set({ activePanelId: null }) }) expect(h.controller.mounted.getSnapshot()).toBe(SESSION) expect(h.opened).toHaveLength(2) @@ -343,7 +349,7 @@ describe('RightbarSeat presentation', () => { expect(document.querySelector('[data-slot-error]')).toBeNull() }) - it('stays bound through its own store commits', async () => { + it('keeps the on-screen Session through its own store commits', async () => { const h = await mountSeat(1440, true, 0, true) const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) const published: (SessionId | undefined)[] = [] @@ -362,6 +368,28 @@ describe('RightbarSeat presentation', () => { } finally { stop() } }) + it('names the arriving Session before a Conversation opener acts in the commit that switches Sessions', async () => { + const h = await mountSeat(1440, true, 0, true) + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + // The opener holds its own reference, as the Conversation does for its Session. + const conversation = h.runtime.sessions.retainFor(h.runtime.ctx, SESSION) + act(() => { h.openerView().update({ armed: false }, { session: conversation }) }) + await act(async () => { await h.runtime.sessions.add({ id: OTHER }) }) + await act(async () => { h.selectSession(OTHER) }) + expect(h.controller.mounted.getSnapshot()).toBe(OTHER) + // Switching back swaps the seats in the commit that mounts the opener: the + // departing seat leaves, and the arriving one renders after the opener. + await act(async () => { + h.selectSession(SESSION) + h.openerView().update({ armed: true }, { session: conversation }) + }) + expect(h.controller.mounted.getSnapshot()).toBe(SESSION) + expect(h.opened).toHaveLength(1) + expect(Object.values(h.layout().tabs).map(tab => tab.contentId)).toContain(h.opened[0]) + expect(errors.mock.calls.map(call => String(call[0])).filter(text => text.includes('slot entry crashed'))).toEqual([]) + conversation.release() + }) + it.each([0, 1, 2])('selects the default from %i guide entries and protects only a sole guide', async (entryCount) => { const h = await mountSeat(1440, true, entryCount) act(() => { h.controller.toggleExpanded() }) diff --git a/packages/client/ui-sidebar-right/tests/service.client.spec.ts b/packages/client/ui-sidebar-right/tests/service.client.spec.ts index 65b520ce97..9dd8979cea 100644 --- a/packages/client/ui-sidebar-right/tests/service.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/service.client.spec.ts @@ -2,8 +2,8 @@ * What `ctx.sidebarRight` promises other plugins. * * The service is root-scoped and the surface is per session, so every command - * depends on a binding the mounted seat publishes; the interesting cases are all - * about that seam. Opening is asserted against a real store instance, because + * acts on the Session the plugin names as on screen, through the store the + * runtime minted for it; the interesting cases are all about that seam. Opening is asserted against a real store instance, because * "already open" and "in that tab's place" mean whatever the planner means by * them and nothing else, and against the Tab domain, because an open is not * complete until the tab knows how it was navigated to. @@ -31,7 +31,7 @@ const SESSION = 's-test' as SessionId /** Key-echoing translate: this file asserts behaviour, not copy. */ const t = ((key: string) => key) as Parameters[0] -/** A controller over a real registry and a real store, bound as a mounted seat would be. */ +/** A controller over a real registry and a real store, adopted as the runtime mints it. */ function harness() { const ctx = new Context() const tabs = new SidebarRightTabRegistry(ctx) @@ -47,25 +47,26 @@ function harness() { title: address => address.slice(address.lastIndexOf('/') + 1), }) const pin = vi.fn<(address: string, signal: AbortSignal) => void>() - const { controller, adopt } = createSidebarRightController(tabs, pin) + const { controller, adopt, show, measure } = createSidebarRightController(tabs, pin, { + autoFullscreen: () => false, + openWithFocus: (_sessionId, open) => { open() }, + closeWithFocus: (_sessionId, _paneId, close) => { close() }, + }) const instance = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed' })).create() + // The runtime mints the Session's store before any seat renders, and the plugin adopts it. + const unadopt = adopt(SESSION, instance) const layout = (): LayoutState => { const surface = instance.getSnapshot().bySession[SESSION] if (surface === undefined) throw new Error('expected a surface') return surface.layout } - /** Republish the way the seat does after each commit, and sync the domain the way it does too. */ /** The room rule's verdict the seat would report; a spec flips it to model a narrow pane. */ const room = { allowed: true } + /** Put the Session on screen the way the plugin does from the selection, with the room rule its seat reports. */ const publish = (): (() => void) => { - const surface = instance.getSnapshot().bySession[SESSION] - if (surface !== undefined) controller.tabDomain.sync(SESSION, surface.layout) - return controller.bind({ - sessionId: SESSION, actions: instance.actions, surfaces: instance.getSnapshot().bySession, - closeWithFocus: (_paneId, close) => { close() }, - openWithFocus: (open) => { open() }, - canSplitPane: () => room.allowed, - }) + show(SESSION) + measure(SESSION, () => room.allowed) + return () => { show(undefined) } } const titles = (): string[] => { const surface = instance.getSnapshot().bySession[SESSION] @@ -79,7 +80,7 @@ function harness() { const entries = (): number => instance.getSnapshot().bySession[SESSION]?.history.entries.length ?? 0 // A surface starts empty; expanding seeds the default guide ('seed'). const expand = (): void => { instance.actions.setExpanded(SESSION, true) } - return { controller, adopt, tabs, instance, pin, publish, titles, tabOf, layout, entries, room, expand } + return { controller, adopt, unadopt, show, measure, tabs, instance, pin, publish, titles, tabOf, layout, entries, room, expand } } describe('SidebarRightController — opening', () => { @@ -102,7 +103,7 @@ describe('SidebarRightController — opening', () => { } finally { release() } }) - it('refuses every write while no seat is mounted', () => { + it('refuses every write while no Session is on screen', () => { const { controller } = harness() expect(() => { controller.openResource('dsh-resource://file/session/s-test/a.txt') }).toThrow('no session surface is mounted') expect(() => { controller.openTab('guide') }).toThrow('no session surface is mounted') @@ -114,21 +115,19 @@ describe('SidebarRightController — opening', () => { expect(() => { controller.dock('pane1' as PaneId) }).toThrow('no session surface is mounted') }) - it('publishes the mounted session only on real transitions of the seat binding', () => { + it('publishes the on-screen Session only when it changes', () => { const h = harness() const seen: (string | undefined)[] = [] const unsubscribe = h.controller.mounted.subscribe(() => { seen.push(h.controller.mounted.getSnapshot()) }) try { expect(h.controller.mounted.getSnapshot()).toBeUndefined() - const first = h.publish() + h.show(SESSION) expect(h.controller.mounted.getSnapshot()).toBe(SESSION) - // The seat republishes on every store commit; the same session is silent. - const second = h.publish() + // Naming the same Session again, and the Session's own store commits, are silent. + h.show(SESSION) + h.expand() expect(seen).toEqual([SESSION]) - // A stale release — the first seat's, after a newer one took over — changes nothing. - first() - expect(h.controller.mounted.getSnapshot()).toBe(SESSION) - second() + h.show(undefined) expect(h.controller.mounted.getSnapshot()).toBeUndefined() expect(seen).toEqual([SESSION, undefined]) } finally { unsubscribe() } @@ -519,25 +518,14 @@ describe('SidebarRightController — a tab\'s own actions', () => { const B_TXT = 'dsh-resource://file/session/s-test/b.txt' it('opens into an adopted store before that store has created its session surface', () => { - const { controller, adopt, instance, titles } = harness() - const releaseOwn = adopt(SESSION, instance) - const other = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed' })).create(OTHER) - const releaseBinding = controller.bind({ - sessionId: OTHER, - actions: other.actions, - surfaces: other.getSnapshot().bySession, - closeWithFocus: (_paneId, close) => { close() }, - openWithFocus: (open) => { open() }, canSplitPane: () => true, - }) + const { controller, show, titles } = harness() + show(OTHER) controller.openResourceIn(SESSION, A_TXT) expect(titles()).toContain('a.txt') - releaseBinding() - releaseOwn() }) - it('land in the session the tab is in through its own adopted store, after another session\'s seat took over', () => { - const { controller, adopt, instance, publish, layout, expand } = harness() - const releaseOwn = adopt(SESSION, instance) + it('land in the session the tab is in through its own adopted store, after another session came on screen', () => { + const { controller, adopt, show, publish, layout, expand } = harness() expand() publish() controller.openResource(A_TXT) @@ -546,7 +534,7 @@ describe('SidebarRightController — a tab\'s own actions', () => { if (own === undefined || guide === undefined) throw new Error('expected the opened tab and the seeded guide') const fromOwn = controller.tabDomain.occurrence(SESSION, own).tabActions const guideOccurrence = controller.tabDomain.occurrence(SESSION, guide) - // The user switches sessions: the other seat binds with the other session's + // The user switches sessions: the other Session comes on screen with its // own instance, whose store knows nothing of this session. const other = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed' })).create(OTHER) const releaseOther = adopt(OTHER, other) @@ -554,12 +542,7 @@ describe('SidebarRightController — a tab\'s own actions', () => { other.actions.setExpanded(OTHER, true) const otherSurface = other.getSnapshot().bySession[OTHER] if (otherSurface === undefined) throw new Error('expected the other surface') - controller.bind({ - sessionId: OTHER, actions: other.actions, surfaces: other.getSnapshot().bySession, - closeWithFocus: (_paneId, close) => { close() }, - openWithFocus: (open) => { open() }, - canSplitPane: () => true, - }) + show(OTHER) fromOwn.openResource(B_TXT) fromOwn.openTab('guide', { revealIfOpened: false }) expect(Object.values(layout().tabs).map(tab => tab.title)).toContain('b.txt') @@ -574,17 +557,17 @@ describe('SidebarRightController — a tab\'s own actions', () => { expect(other.getSnapshot().bySession[OTHER]).toBe(otherSurface) expect(controller.active()?.kind).toBe('guide') releaseOther() - releaseOwn() }) it('do nothing for a session whose store is not adopted, and again once its adoption is released', () => { - const { controller, adopt, instance, publish, layout, titles } = harness() + const { controller, adopt, unadopt, instance, publish, layout, titles } = harness() publish() controller.openResource(A_TXT) - publish() const own = Object.values(layout().tabs).find(tab => tab.title === 'a.txt') if (own === undefined) throw new Error('expected the opened tab') const { tabActions } = controller.tabDomain.occurrence(SESSION, own) + // The runtime disposed the Session's store generation. + unadopt() const before = instance.getSnapshot().bySession tabActions.openResource(B_TXT) tabActions.openTab('guide') @@ -661,17 +644,16 @@ describe('SidebarRightController — the readable slice', () => { }) }) -describe('SidebarRightController — binding lifetime', () => { - it('acts on the newest binding when a seat republishes', () => { +describe('SidebarRightController — the on-screen Session', () => { + it('acts on the on-screen Session through its adopted store', () => { const { controller, publish, layout } = harness() publish() controller.toggleExpanded() - publish() expect(controller.isExpanded()).toBe(true) expect(layout().expanded).toBe(true) }) - it('goes back to refusing writes once the seat releases', () => { + it('goes back to refusing writes once no Session is on screen', () => { const { controller, publish } = harness() const release = publish() release() @@ -679,12 +661,20 @@ describe('SidebarRightController — binding lifetime', () => { expect(controller.isExpanded()).toBe(false) }) - it('a stale release does not clear a newer binding', () => { - const { controller, publish } = harness() - const stale = publish() - publish() - stale() - expect(() => { controller.toggleExpanded() }).not.toThrow() + it('refuses writes for an on-screen Session whose store the runtime has not minted', () => { + const { controller, show } = harness() + show('s-unminted' as SessionId) + expect(() => { controller.openResource('dsh-resource://file/session/s-test/a.txt') }).toThrow('no session surface is mounted') + expect(controller.active()).toBeUndefined() + }) + + it('treats panes its seat has not measured as having room', () => { + const { controller, show, instance, layout, expand } = harness() + instance.actions.open(SESSION) + expand() + show(SESSION) + expect(controller.split()).toBeDefined() + expect(dockPaneIds(layout())).toHaveLength(2) }) }) diff --git a/packages/client/ui-sidebar/tests/panel-list.client.spec.tsx b/packages/client/ui-sidebar/tests/panel-list.client.spec.tsx index 5673aa95ad..68123aa63e 100644 --- a/packages/client/ui-sidebar/tests/panel-list.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/panel-list.client.spec.tsx @@ -39,6 +39,7 @@ async function bench(collapsed = false) { locale.setLocale('en') const layout = { panelInfo: runtime.panelInfo, + viewportWidth: createSnapshotStore(1440), beginNavigation: vi.fn(() => new AbortController().signal), toggleSidebar: vi.fn(), selectPanel: vi.fn((activePanelId: MainPanelId | null) => { runtime.panelInfo.set({ activePanelId }) }), diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index aae7d728ac..98053e81db 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -279,7 +279,7 @@ function bench(options: BenchOptions = {}) { selectPanel: vi.fn(), retainMainPanels: vi.fn(), setSidebar: vi.fn(), toggleSidebar: vi.fn(), setViewportWidth: vi.fn(), setRightbar: vi.fn(), openRightbar: vi.fn(), closeRightbar: vi.fn(), - }, () => true, createSnapshotStore({ activePanelId: null })) + }, () => true, createSnapshotStore({ activePanelId: null }), createSnapshotStore(1440)) const selectPanel = vi.spyOn(layout, 'selectPanel') ctx.provide('layout', layout) ctx.effect(() => () => { layout.dispose() }) From 4ccf48c4efea5ad480f94273c7951d7acdddcfa2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 27 Sep 2026 17:02:34 +0800 Subject: [PATCH 03/42] refactor(sidebar-right): take the auto-fullscreen rule from the seats The fullscreen command needs to know whether the frame is narrow enough that an expanded panel is fullscreen automatically. Reading it from a new ctx.layout.viewportWidth widened the layout plugin's public face for this one consumer. The seats already receive the frame width as owner props and compute the rule they render with, so each seat now reports that verdict through its inject face, as it reports its docking kit's room. The command reads the latest report only when the user runs it, after the commit that rendered the width has settled. - Revert ctx.layout.viewportWidth and the test fakes that provided it. - SidebarRightInjected gains reportAutoFullscreen. --- ...sidebar-tab-types-and-navigation.i18n.yaml | 4 ++-- ...-09-05-sidebar-tab-types-and-navigation.md | 2 +- ...-05-sidebar-tab-types-and-navigation.zh.md | 2 +- packages/client/ui-layout/README.i18n.yaml | 4 ++-- packages/client/ui-layout/README.md | 2 +- packages/client/ui-layout/README.zh.md | 2 +- packages/client/ui-layout/src/client/index.ts | 6 +---- .../client/ui-layout/src/client/service.ts | 4 ---- .../ui-layout/tests/apply.client.spec.ts | 18 --------------- .../ui-layout/tests/service.client.spec.ts | 15 ++++++------ .../tests/browser-plugin.client.spec.ts | 3 --- .../tests/document-seat.client.spec.tsx | 2 +- .../client/ui-sidebar-right/README.i18n.yaml | 4 ++-- packages/client/ui-sidebar-right/README.md | 2 +- packages/client/ui-sidebar-right/README.zh.md | 2 +- .../ui-sidebar-right/src/client/index.ts | 8 +++++-- .../ui-sidebar-right/src/client/service.ts | 7 +++--- .../src/client/shell/SidebarRight.tsx | 23 +++++++++++++------ .../tests/apply.client.spec.ts | 8 ++++++- .../tests/seat.client.spec.tsx | 20 +++++++++++++--- .../tests/panel-list.client.spec.tsx | 1 - .../tests/workspaces-service.client.spec.ts | 2 +- 22 files changed, 72 insertions(+), 69 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml index bd3e2ebb5e..e80e658859 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml @@ -21,8 +21,8 @@ en: 774c3bf80a48ffbe zh: c513245789aa8efd /agent-note-right-sidebar-tab-types-and-navigation/decision/navigation-ctx-sidebarright: - en: c70040a8041e3d06 - zh: a6958514a35733c4 + en: c52363f57001b7ac + zh: bbcbdbc038cbf316 /agent-note-right-sidebar-tab-types-and-navigation/decision/addresses: en: eeb1e7e7478a2f2c zh: bddbc5bc78245f63 diff --git a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md index ba21a60ce3..4e7fcb8cb1 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md +++ b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md @@ -72,7 +72,7 @@ interface SidebarRightTabParamsMap {} // key: kind — a page type declar `openResource` accepts the union of every declared resource shape and `openTab` the shape declared for `K`; a body narrows `navigation.params` by the protocol or kind it knows it serves. Parameters belong to the resource type rather than to the viewer because a line number is a fact about a file location, not about the text preview, and any type that claims `file` addresses receives the same shape. Values must be JSON-serializable, and a record must be rebuildable from address and parameters alone, because undo, redo, reload, and HMR rebuild tabs after the opener is gone. -Beside the two opens, the face carries `close(tabId)`, `active()`, `isExpanded()`, `toggleExpanded()`, and four operational methods — `focus(tabId)`, `split(paneId?)` (returning the new pane, or `undefined` when the pane budget or the width rule forbids the split, recording nothing), `float(tabId, rect?)`, and `dock(paneId)` — each recording one history entry and a no-op on a missing target or one already in the requested state. There is no layout snapshot, no subscription, and no lookup by address: the face grants control over the layout, not a view of it. The plugin names the Session on screen — the selected Session while the Conversation fills the main column — from the Session selection and the main panel, before React renders either change, and publishes it as `mounted`; a command on the public face acts on that Session through its adopted store and throws when no Session is on screen or its store has not been minted. The seat reports only the room its docking kit measured. A tab's own actions reach their session's own store instead: the slot runtime mints one store per session, the plugin adopts each as it is minted, and the controller routes by session id, so an action fired after the user switched sessions still lands, and does nothing for a session whose store was never minted. +Beside the two opens, the face carries `close(tabId)`, `active()`, `isExpanded()`, `toggleExpanded()`, and four operational methods — `focus(tabId)`, `split(paneId?)` (returning the new pane, or `undefined` when the pane budget or the width rule forbids the split, recording nothing), `float(tabId, rect?)`, and `dock(paneId)` — each recording one history entry and a no-op on a missing target or one already in the requested state. There is no layout snapshot, no subscription, and no lookup by address: the face grants control over the layout, not a view of it. The plugin names the Session on screen — the selected Session while the Conversation fills the main column — from the Session selection and the main panel, before React renders either change, and publishes it as `mounted`; a command on the public face acts on that Session through its adopted store and throws when no Session is on screen or its store has not been minted. The seat reports only what it renders with: the room its docking kit measured and whether its frame width presents the panel fullscreen automatically. A tab's own actions reach their session's own store instead: the slot runtime mints one store per session, the plugin adopts each as it is minted, and the controller routes by session id, so an action fired after the user switched sessions still lands, and does nothing for a session whose store was never minted. ### Addresses diff --git a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md index a5d357b9b7..ca10161a62 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md @@ -72,7 +72,7 @@ interface SidebarRightTabParamsMap {} // key: kind — a page type declar `openResource` 接受所有已声明资源形状的联合,`openTab` 接受为 `K` 声明的形状;正文按自己所服务的协议或 kind 收窄 `navigation.params`。参数属于资源类型而非查看器,因为行号是关于文件位置的事实,不是关于文本预览的,任何认领 `file` 地址的类型收到同一形状。值必须可 JSON 序列化,一条记录必须只凭地址与参数就能重建,因为撤销、重做、刷新与 HMR 都在开启方已不在时重建 tab。 -除两种打开外,该面还有 `close(tabId)`、`active()`、`isExpanded()`、`toggleExpanded()`,以及四个操作型方法——`focus(tabId)`、`split(paneId?)`(返回新格,预算或宽度规则不允许分栏时返回 `undefined` 且不记账)、`float(tabId, rect?)` 与 `dock(paneId)`——每个记一条历史,目标不存在或已在目标态时为 no-op。没有布局快照、没有订阅、没有按地址查找:该面给的是对布局的控制权,不是布局的视图。插件依据会话选择与主面板确定屏上会话——Conversation 占据主栏时的选中会话——并在 React 渲染这两者的变化之前将其发布为 `mounted`;公开面上的命令经该会话被收养的 store 作用于它,没有会话在屏上或其 store 尚未铸出时抛错。座位只上报其停靠套件测得的空间。tab 自己的动作则到达其会话自己的 store:slot 运行时每个会话铸一个 store,插件在铸出时逐个收养,控制器按会话 id 路由,因此用户切换会话之后触发的动作照样落地,而 store 从未铸出的会话什么也不做。 +除两种打开外,该面还有 `close(tabId)`、`active()`、`isExpanded()`、`toggleExpanded()`,以及四个操作型方法——`focus(tabId)`、`split(paneId?)`(返回新格,预算或宽度规则不允许分栏时返回 `undefined` 且不记账)、`float(tabId, rect?)` 与 `dock(paneId)`——每个记一条历史,目标不存在或已在目标态时为 no-op。没有布局快照、没有订阅、没有按地址查找:该面给的是对布局的控制权,不是布局的视图。插件依据会话选择与主面板确定屏上会话——Conversation 占据主栏时的选中会话——并在 React 渲染这两者的变化之前将其发布为 `mounted`;公开面上的命令经该会话被收养的 store 作用于它,没有会话在屏上或其 store 尚未铸出时抛错。座位只上报它渲染时依据的事实:停靠套件测得的空间,以及框架宽度是否让面板自动全屏。tab 自己的动作则到达其会话自己的 store:slot 运行时每个会话铸一个 store,插件在铸出时逐个收养,控制器按会话 id 路由,因此用户切换会话之后触发的动作照样落地,而 store 从未铸出的会话什么也不做。 ### 地址 diff --git a/packages/client/ui-layout/README.i18n.yaml b/packages/client/ui-layout/README.i18n.yaml index 8db8483954..24a9220917 100644 --- a/packages/client/ui-layout/README.i18n.yaml +++ b/packages/client/ui-layout/README.i18n.yaml @@ -24,8 +24,8 @@ en: 1e1d3361dac7d8b0 zh: b6b3d210d65738f0 /deepseek-ai-dsh-client-ui-layout/understand-the-implementation: - en: d3a95bd98997ff42 - zh: cad20230a114a1ec + en: 8af800fa55786c71 + zh: 7421c12eb3585c21 /deepseek-ai-dsh-client-ui-layout/further-exploration: en: b6c728b6ed70f2a7 zh: 8ee2003b98695323 diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index 60a7d10b7d..e1fab07fff 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -51,7 +51,7 @@ The presenter consumes resolved theme snapshots and projects them onto the docum `selectPanel(id)` checks the live `main` registry before changing selection; an absent key throws and leaves the current panel intact. `beginNavigation()` returns an abort signal for an asynchronous UI navigation. A later call, a valid panel selection (including repeated selection), or layout disposal aborts that signal without cancelling underlying Session creation. Consumers check the signal before committing navigation or moving drafts. -One registration declares five child slots and binds `ctx.layout` methods `selectPanel`, `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. One root store separates `panelInfo` selection from `layoutInfo` measurements, width preferences, and presentation reports. `ctx.layout.panelInfo` and the standard `usePanelInfo` hook share the same selection source; `ctx.layout.viewportWidth` publishes the frame's measured width from the same store, the value the `rightbar` owner receives as `viewportWidth`. AppFrame subscribes to the stable layout object. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame keeps the column containers mounted. The right column's root controller renders `rightbar.session` through `SessionProvider` only while the Conversation is selected; its unmount report releases the track. The independent title component uses the selected Session title only while the Conversation is visible, with the build-configured product title or localized `common.brand.localBuild` as its fallback; locale revisions update that fallback. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions. +One registration declares five child slots and binds `ctx.layout` methods `selectPanel`, `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. One root store separates `panelInfo` selection from `layoutInfo` measurements, width preferences, and presentation reports. `ctx.layout.panelInfo` and the standard `usePanelInfo` hook share the same selection source; AppFrame subscribes to the stable layout object. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame keeps the column containers mounted. The right column's root controller renders `rightbar.session` through `SessionProvider` only while the Conversation is selected; its unmount report releases the track. The independent title component uses the selected Session title only while the Conversation is visible, with the build-configured product title or localized `common.brand.localBuild` as its fallback; locale revisions update that fallback. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions. diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md index 5d8e47fe40..29d0633b7f 100644 --- a/packages/client/ui-layout/README.zh.md +++ b/packages/client/ui-layout/README.zh.md @@ -51,7 +51,7 @@ Windows Electron 的 `data-windows-titlebar` 标记在所有列上方预留顶 `selectPanel(id)` 在改变选中态前检查实时 `main` 注册表;缺失的 key 会抛错并保留当前面板。`beginNavigation()` 为异步 UI 导航返回 abort signal。后续调用、有效面板选择(包括重复选择)或布局释放会中止该 signal,但不取消底层会话创建。消费方在提交导航或搬移草稿前检查 signal。 -一次注册声明五个子 slot,并绑定 `ctx.layout` 的 `selectPanel`、`toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。同一个 root 存储把 `panelInfo` 选中态与 `layoutInfo` 测量、宽度偏好、呈现报告分开。`ctx.layout.panelInfo` 与标准 `usePanelInfo` hook 共用同一个选中态来源;`ctx.layout.viewportWidth` 从同一存储发布框架测得的宽度,即 `rightbar` owner 收到的 `viewportWidth`。AppFrame 订阅引用稳定的布局对象。`rightbar` owner 提供实际 `width`、`viewportWidth`,以及表示能否以普通模式呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 保持各列容器挂载。右栏的 root 控制器仅在选中会话界面时,经 `SessionProvider` 渲染 `rightbar.session`;内容卸载时的报告释放轨道。独立的标题组件仅在会话界面可见时使用所选会话标题,以构建配置的产品标题或本地化 `common.brand.localBuild` 为回退值;语言变化会更新该回退值。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列布局。退出全屏时,框架先保持无过渡并安装目标布局:关闭移除右轨道,恢复保留右轨道。后续普通几何操作恢复正常过渡。 +一次注册声明五个子 slot,并绑定 `ctx.layout` 的 `selectPanel`、`toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。同一个 root 存储把 `panelInfo` 选中态与 `layoutInfo` 测量、宽度偏好、呈现报告分开。`ctx.layout.panelInfo` 与标准 `usePanelInfo` hook 共用同一个选中态来源;AppFrame 订阅引用稳定的布局对象。`rightbar` owner 提供实际 `width`、`viewportWidth`,以及表示能否以普通模式呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 保持各列容器挂载。右栏的 root 控制器仅在选中会话界面时,经 `SessionProvider` 渲染 `rightbar.session`;内容卸载时的报告释放轨道。独立的标题组件仅在会话界面可见时使用所选会话标题,以构建配置的产品标题或本地化 `common.brand.localBuild` 为回退值;语言变化会更新该回退值。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列布局。退出全屏时,框架先保持无过渡并安装目标布局:关闭移除右轨道,恢复保留右轨道。后续普通几何操作恢复正常过渡。 diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index da1f7513c0..410c8b0977 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -164,12 +164,8 @@ export function apply(ctx: ClientContext): void { getSnapshot: () => instance.getSnapshot().panelInfo, subscribe: listener => instance.subscribe(listener), } - const viewportWidth: HostObservable = { - getSnapshot: () => instance.getSnapshot().layoutInfo.viewportWidth, - subscribe: listener => instance.subscribe(listener), - } const layout = new LayoutController(instance.actions, id => - ctx.slots.entries('main').some(entry => entry.options.key === id), panelInfo, viewportWidth) + ctx.slots.entries('main').some(entry => entry.options.key === id), panelInfo) const disposePanelInfo = ctx.slots.provideRoot({ hooks: { panelInfo: layout.panelInfo } }) const disposeService = ctx.reflect.provide('layout', layout) const disposeRegistration = ctx.slots.register({ diff --git a/packages/client/ui-layout/src/client/service.ts b/packages/client/ui-layout/src/client/service.ts index 2719d30f0a..22dd77a4e6 100644 --- a/packages/client/ui-layout/src/client/service.ts +++ b/packages/client/ui-layout/src/client/service.ts @@ -28,8 +28,6 @@ export type PanelActions = BoundActions> export interface ILayout { /** Selected central panel from the same root store used by `usePanelInfo`. */ readonly panelInfo: HostObservable - /** The frame's measured width in CSS pixels, from the same root store; the `rightbar` owner receives it as `viewportWidth`. */ - readonly viewportWidth: HostObservable /** * Select a global central panel without changing the current Session. * @param panelId - registered main key, or null to show the Conversation. @@ -63,13 +61,11 @@ export class LayoutController implements ILayout { * @param panels - actions of the instance shared with the root entry. * @param hasMainPanel - checks the live main-slot registry for a panel id. * @param panelInfo - root store's shared central-panel selection source. - * @param viewportWidth - root store's measured frame width source. */ constructor( private readonly panels: PanelActions, private readonly hasMainPanel: (id: MainPanelId) => boolean, readonly panelInfo: HostObservable, - readonly viewportWidth: HostObservable, ) {} /** Select a global panel or return to the Conversation. */ diff --git a/packages/client/ui-layout/tests/apply.client.spec.ts b/packages/client/ui-layout/tests/apply.client.spec.ts index 5c1cf328ac..c38719c68d 100644 --- a/packages/client/ui-layout/tests/apply.client.spec.ts +++ b/packages/client/ui-layout/tests/apply.client.spec.ts @@ -95,24 +95,6 @@ describe('ui-layout client apply', () => { expect(slots.spec('shell.leading')).toEqual({ kind: 'single', scope: 'root' }) }) - it('publishes the frame\'s measured viewport width from the root store', async () => { - const { ctx, slots } = await bench() - const fiber = ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - const instance = (slots.entries('root')[0]!.store as ReturnType).create() - const layout = ctx.get('layout') as LayoutController - const seen: number[] = [] - const unsubscribe = layout.viewportWidth.subscribe(() => { seen.push(layout.viewportWidth.getSnapshot()) }) - try { - instance.actions.setViewportWidth(700) - expect(layout.viewportWidth.getSnapshot()).toBe(700) - expect(seen).toContain(700) - } finally { - unsubscribe() - await fiber.dispose() - } - }) - it('shares a pre-created instance between service actions, root rendering, and panelInfo', async () => { const { ctx, slots, rendererHost } = await bench() const fiber = ctx.plugin({ inject: [...inject], apply }) diff --git a/packages/client/ui-layout/tests/service.client.spec.ts b/packages/client/ui-layout/tests/service.client.spec.ts index 6fe0593c06..b3e3680d5c 100644 --- a/packages/client/ui-layout/tests/service.client.spec.ts +++ b/packages/client/ui-layout/tests/service.client.spec.ts @@ -19,7 +19,7 @@ function fakePanels(): PanelActions { describe('LayoutController', () => { it('forwards right column transitions to the constructor-supplied actions', () => { const panels = fakePanels() - const service = new LayoutController(panels, () => true, createSnapshotStore({ activePanelId: null }), createSnapshotStore(1440)) + const service = new LayoutController(panels, () => true, createSnapshotStore({ activePanelId: null })) service.openRightbar(true, false) service.openRightbar(true, true) @@ -36,7 +36,7 @@ describe('LayoutController', () => { it('can toggle the sidebar immediately after construction', () => { const panels = fakePanels() - const service = new LayoutController(panels, () => true, createSnapshotStore({ activePanelId: null }), createSnapshotStore(1440)) + const service = new LayoutController(panels, () => true, createSnapshotStore({ activePanelId: null })) service.toggleSidebar() @@ -46,7 +46,7 @@ describe('LayoutController', () => { it('forwards panel selection and returning to the Conversation without changing geometry', () => { const panels = fakePanels() - const service = new LayoutController(panels, () => true, createSnapshotStore({ activePanelId: null }), createSnapshotStore(1440)) + const service = new LayoutController(panels, () => true, createSnapshotStore({ activePanelId: null })) const panelId = 'panel-a' as MainPanelId service.selectPanel(panelId) service.selectPanel(panelId) @@ -62,8 +62,8 @@ describe('LayoutController', () => { it('keeps separately constructed controllers bound to their own instances', () => { const first = fakePanels() const second = fakePanels() - const firstService = new LayoutController(first, () => true, createSnapshotStore({ activePanelId: null }), createSnapshotStore(1440)) - const secondService = new LayoutController(second, () => true, createSnapshotStore({ activePanelId: null }), createSnapshotStore(1440)) + const firstService = new LayoutController(first, () => true, createSnapshotStore({ activePanelId: null })) + const secondService = new LayoutController(second, () => true, createSnapshotStore({ activePanelId: null })) firstService.toggleSidebar() expect(first.toggleSidebar).toHaveBeenCalledTimes(1) expect(second.toggleSidebar).not.toHaveBeenCalled() @@ -75,8 +75,7 @@ describe('LayoutController', () => { it('rejects an absent main entry without changing selection or cancelling pending navigation', () => { const panels = fakePanels() const present = new Set(['panel-a']) - const service = new LayoutController( - panels, id => present.has(id), createSnapshotStore({ activePanelId: null }), createSnapshotStore(1440)) + const service = new LayoutController(panels, id => present.has(id), createSnapshotStore({ activePanelId: null })) service.selectPanel('panel-a' as MainPanelId) const navigation = service.beginNavigation() present.delete('panel-a') @@ -88,7 +87,7 @@ describe('LayoutController', () => { }) it('supersedes asynchronous navigation on another request, any valid selection, and disposal', () => { - const service = new LayoutController(fakePanels(), () => true, createSnapshotStore({ activePanelId: null }), createSnapshotStore(1440)) + const service = new LayoutController(fakePanels(), () => true, createSnapshotStore({ activePanelId: null })) const first = service.beginNavigation() const second = service.beginNavigation() expect(first.aborted).toBe(true) diff --git a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts index 7bf9688646..2fce5eb64e 100644 --- a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts @@ -57,9 +57,6 @@ async function bench() { const layout = new LayoutController(layoutStore.actions, id => id === 'plugins', { getSnapshot: () => layoutStore.getSnapshot().panelInfo, subscribe: listener => layoutStore.subscribe(listener), - }, { - getSnapshot: () => layoutStore.getSnapshot().layoutInfo.viewportWidth, - subscribe: listener => layoutStore.subscribe(listener), }) ctx.provide('layout', layout) ctx.provide('sessions', { list }) diff --git a/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx index e9a44767f0..da67649ce5 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx @@ -40,7 +40,7 @@ async function boot() { const rt = await SlotTestRuntime.create() runtime = rt rt.ctx.provide('configForms', { developerTools: { enabled: createSnapshotStore(true) } } as never) - rt.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn(), panelInfo: rt.panelInfo, viewportWidth: createSnapshotStore(1440) } as never) + rt.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn(), panelInfo: rt.panelInfo } as never) rt.ctx.provide('shortcuts', { register: () => () => {}, catalog: { getSnapshot: () => SHORTCUT_CATALOG, subscribe: () => () => {} } } as never) const locale = new LocaleRuntime(rt.ctx) rt.ctx.provide('locale', locale) diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml index 4821d5d917..dfd1b69803 100644 --- a/packages/client/ui-sidebar-right/README.i18n.yaml +++ b/packages/client/ui-sidebar-right/README.i18n.yaml @@ -30,8 +30,8 @@ en: ef2af36136c4a919 zh: 48d992e2c923a130 /deepseek-ai-dsh-client-ui-sidebar-right/ctx-sidebarright: - en: 1b4b0a47625d91c1 - zh: 7878c3dcaa4c2811 + en: 4519c4633d160c2a + zh: e4019a62532f885a /deepseek-ai-dsh-client-ui-sidebar-right/the-tab-domain: en: 717b86e05b44ebdd zh: 0f4f694d0ea1d7f8 diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md index 3b19cb7fec..980b192ce0 100644 --- a/packages/client/ui-sidebar-right/README.md +++ b/packages/client/ui-sidebar-right/README.md @@ -94,7 +94,7 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r `preferNewPane: true` first asks the target docked pane to split under the normal two-pane and room rules, then falls back to that pane when no split is available. `replaceTab` takes precedence and disables this preference. -`close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; `toggleFullscreen(target)` applies the panel control’s display-mode action to a captured dock pane. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the on-screen surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands act on the on-screen Session through its adopted store; with no Session on screen, or before the runtime mints its store, they throw rather than write into a surface nobody draws. `mounted` is the observable of the Session on screen: the selected Session while the Conversation fills the main column, `undefined` while a global panel replaces it or no Session is selected. The plugin derives it from the Session selection and `ctx.layout.panelInfo`, and it changes before React renders the change that causes it, so a component rendered in that commit reads the arriving Session and may act on it from its effects: the frame renders that Session's seat in the same pass, which mints its store. Seats never publish it; each reports only the room its docking kit measured, which `split` and `preferNewPane` apply to that Session's panes, and unmeasured panes have room. The plan review's automatic open reads `mounted` through a bound hook. +`close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; `toggleFullscreen(target)` applies the panel control’s display-mode action to a captured dock pane. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the on-screen surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands act on the on-screen Session through its adopted store; with no Session on screen, or before the runtime mints its store, they throw rather than write into a surface nobody draws. `mounted` is the observable of the Session on screen: the selected Session while the Conversation fills the main column, `undefined` while a global panel replaces it or no Session is selected. The plugin derives it from the Session selection and `ctx.layout.panelInfo`, and it changes before React renders the change that causes it, so a component rendered in that commit reads the arriving Session and may act on it from its effects: the frame renders that Session's seat in the same pass, which mints its store. Seats never publish it; each reports only what it renders with: the room its docking kit measured, which `split` and `preferNewPane` apply to that Session's panes (unmeasured panes have room), and whether its frame width presents the panel fullscreen automatically, which the fullscreen command applies. The plan review's automatic open reads `mounted` through a bound hook. `focusedTarget(element?)` resolves the current DOM pane and tab, including an embedding iframe; outside focus returns no page. `commandTarget(element?)` additionally allows an outside open to use the on-screen Session's active dock pane. Captures include the occurrence and navigation revision; `isTargetCurrent(target)` rejects changed Sessions, moved or reopened tabs, and intervening navigation. Focus and pointer activation remain separate from persisted layout selection. diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md index 9aa5a99954..96f8e284d9 100644 --- a/packages/client/ui-sidebar-right/README.zh.md +++ b/packages/client/ui-sidebar-right/README.zh.md @@ -94,7 +94,7 @@ tab 类型分两阶段注册,随包发布的引导类型走的正是别的包 `preferNewPane: true` 会先按普通的两格上限与空间规则尝试分割目标停靠格,无法分栏时回退到该格。`replaceTab` 优先并会禁用这项偏好。 -`close(tabId)` 关闭一个 tab;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;`toggleFullscreen(target)` 对捕获的停靠分栏执行面板控件的显示模式操作。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进屏幕上停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令通过屏幕上会话已采用的 store 作用于该会话;没有会话在屏幕上、或运行时尚未创建它的 store 时,命令 throw,而不是写进一个没人绘制的面里。`mounted` 是「屏幕上的会话」的可观察值:Conversation 占据主栏时为选中的会话,全局面板替代它或没有选中会话时为 `undefined`。插件从会话选择与 `ctx.layout.panelInfo` 推导它,它在 React 渲染引起变化的那次更新之前就已改变,因此同一次提交里渲染的组件读到的是到来的会话,并可以在自己的 effect 里对它行动:框架在同一轮渲染里渲染该会话的席位,席位随之创建它的 store。席位从不发布它;每个席位只上报停靠套件测得的空间,`split` 与 `preferNewPane` 把它用于该会话的分栏,未测量的分栏视为有空间。计划审阅的自动打开通过绑定的 hook 读取 `mounted`。 +`close(tabId)` 关闭一个 tab;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;`toggleFullscreen(target)` 对捕获的停靠分栏执行面板控件的显示模式操作。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进屏幕上停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令通过屏幕上会话已采用的 store 作用于该会话;没有会话在屏幕上、或运行时尚未创建它的 store 时,命令 throw,而不是写进一个没人绘制的面里。`mounted` 是「屏幕上的会话」的可观察值:Conversation 占据主栏时为选中的会话,全局面板替代它或没有选中会话时为 `undefined`。插件从会话选择与 `ctx.layout.panelInfo` 推导它,它在 React 渲染引起变化的那次更新之前就已改变,因此同一次提交里渲染的组件读到的是到来的会话,并可以在自己的 effect 里对它行动:框架在同一轮渲染里渲染该会话的席位,席位随之创建它的 store。席位从不发布它;每个席位只上报它渲染时依据的事实:停靠套件测得的空间,`split` 与 `preferNewPane` 把它用于该会话的分栏(未测量的分栏视为有空间);以及框架宽度是否让面板自动全屏,全屏命令据此执行。计划审阅的自动打开通过绑定的 hook 读取 `mounted`。 `focusedTarget(element?)` 解析当前 DOM 分栏和标签页,也接受嵌入 iframe;焦点位于外部时不返回页面。`commandTarget(element?)` 另外允许外部打开动作使用屏幕上会话的活动停靠分栏。捕获值包含 occurrence 和导航版本;`isTargetCurrent(target)` 拒绝会话变更、已移动或重新打开的标签页,以及期间发生的导航。焦点和指针激活独立于持久化的布局选择。 diff --git a/packages/client/ui-sidebar-right/src/client/index.ts b/packages/client/ui-sidebar-right/src/client/index.ts index e21ff3e85f..5974b81723 100644 --- a/packages/client/ui-sidebar-right/src/client/index.ts +++ b/packages/client/ui-sidebar-right/src/client/index.ts @@ -35,7 +35,7 @@ import type {} from './contract/slots.ts' import { GuideBody, type GuideInjected } from './tabs/guide/GuideBody.tsx' import { GuideTitle } from './tabs/guide/GuideTitle.tsx' import { ExpandButton } from './shell/ExpandButton.tsx' -import { AUTO_FULLSCREEN_BELOW, RightbarSeat, type SidebarRightInjected } from './shell/SidebarRight.tsx' +import { RightbarSeat, type SidebarRightInjected } from './shell/SidebarRight.tsx' import { closeWithPaneFocus, openWithPaneFocus } from './shell/close-focus.ts' import { RightbarRoot, type RightbarRootInjected } from './shell/RightbarRoot.tsx' import { SidebarSessionViews } from './session-views.ts' @@ -117,11 +117,14 @@ export function apply(ctx: ClientContext): void { return () => { unsubscribe(); views.dispose() } }, 'ui-sidebar-right: retained Session views') const layout: ILayout = ctx.layout + // The automatic fullscreen rule as the seats last rendered it: the frame + // hands them its width as owner props, and every seat reports the same rule. + let autoFullscreen = false const { controller, adopt, forget, show, measure } = createSidebarRightController( tabs, (address, signal) => { ctx.resources.pin(address, signal) }, { - autoFullscreen: () => layout.viewportWidth.getSnapshot() < AUTO_FULLSCREEN_BELOW, + autoFullscreen: () => autoFullscreen, openWithFocus: (sessionId, open) => { openWithPaneFocus(document, sessionId, open) }, closeWithFocus: (sessionId, paneId, close) => { closeWithPaneFocus(document, sessionId, paneId, close) }, }, @@ -187,6 +190,7 @@ export function apply(ctx: ClientContext): void { if (shown) layout.openRightbar(track, fullscreen) else layout.closeRightbar() }, + reportAutoFullscreen: (value) => { autoFullscreen = value }, splitPane: (paneId) => { controller.split(paneId) }, toggleFullscreen: () => { const target = controller.commandTarget(); if (target !== undefined) controller.toggleFullscreen(target) }, openTab: (kind, options) => { controller.openTab(kind, options) }, diff --git a/packages/client/ui-sidebar-right/src/client/service.ts b/packages/client/ui-sidebar-right/src/client/service.ts index 5ff81e48e4..f056f4b280 100644 --- a/packages/client/ui-sidebar-right/src/client/service.ts +++ b/packages/client/ui-sidebar-right/src/client/service.ts @@ -14,7 +14,8 @@ * command with no Session on screen, or with one whose store the runtime has not * minted, has nothing to act on and fails loudly rather than writing into a * surface nobody is drawing. The seats never publish which Session they draw: - * a seat reports only the room rule its docking kit measured for its panes. + * they report only what they render with, the room their docking kit measured + * and the automatic fullscreen rule of their frame width. * * A tab's own actions (`tabActions`) aim at the session the tab is in, not at * the on-screen one: they run through that session's adopted store, so a callback @@ -30,7 +31,7 @@ * domain. Placement is the caller's option, never a type's property. * * The registration adopts Session stores, names the on-screen Session, and - * forwards each seat's room readings; callers use the service's navigation methods. + * forwards the seats' reports; callers use the service's navigation methods. */ import { sidebarTargetFromElement, type SidebarRightTarget } from './focus.ts' import { randomUUID } from '@deepseek-ai/dsh-util-crypto' @@ -82,7 +83,7 @@ interface SidebarRightSessions { export interface SidebarRightHost { /** * Whether the viewport is narrow enough that an expanded panel is presented fullscreen. - * @returns the rule's verdict for the current frame width. + * @returns the rule's verdict for the frame width the seats last rendered at. */ readonly autoFullscreen: () => boolean /** diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx index 62a4cab3e6..39fc5f6778 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx @@ -84,6 +84,14 @@ export interface SidebarRightInjected { * @param canSplitPane - whether two working halves of a docked pane would fit; unmeasured panes fit. */ readonly measureRoom: (canSplitPane: (paneId: PaneId) => boolean) => void + /** + * Record whether the frame width this seat renders at presents an expanded panel fullscreen automatically. + * + * The fullscreen command reads the latest report: while it holds, the command + * closes the panel instead of switching its recorded mode. + * @param autoFullscreen - whether the frame is narrower than the automatic fullscreen width. + */ + readonly reportAutoFullscreen: (autoFullscreen: boolean) => void /** * The navigation face's `openTab`, for the strip's add control: a new tab is * the guide opened by kind, through the same path as every other open. @@ -336,24 +344,22 @@ function SidebarPanel(panel: PanelProps & { width: number; panelRef: RefObject entries) const surface = useStore(state => state.bySession[sessionId]) const shown = active && surface !== undefined && surface.layout.expanded - const autoFullscreen = viewportWidth < AUTO_FULLSCREEN_BELOW + const autoFullscreen = viewportWidth < 768 const fullscreen = autoFullscreen || surface?.layout.mode === 'fullscreen' const panelRef = useRef(null) // A reading never re-renders anything: the service applies it when it splits. @@ -370,6 +376,9 @@ export function RightbarSeat({ if (shown && !fullscreen && !canShow) actions.setExpanded(sessionId, false) }, [actions, sessionId, shown, fullscreen, canShow]) + // Read only when the fullscreen command runs, after this commit has settled. + useLayoutEffect(() => { reportAutoFullscreen(autoFullscreen) }, [reportAutoFullscreen, autoFullscreen]) + // The open/close slide needs no pulse of its own: the shell's window drag // watcher (ui-web) measures the marked rows every frame the surface moves and // sets the recall mark itself (electron#32341). diff --git a/packages/client/ui-sidebar-right/tests/apply.client.spec.ts b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts index dd364a9c24..fa83737536 100644 --- a/packages/client/ui-sidebar-right/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts @@ -66,7 +66,6 @@ async function boot(shortcuts: Partial = {}) { const layout = { openRightbar: vi.fn(), closeRightbar: vi.fn(), panelInfo: createSnapshotStore<{ activePanelId: string | null }>({ activePanelId: null }), - viewportWidth: createSnapshotStore(1440), } const current = createSnapshotStore<{ key: SessionId | undefined }>({ key: undefined }) const resources = { pin: vi.fn<(address: string, signal: AbortSignal) => void>() } @@ -206,7 +205,14 @@ describe('ui-sidebar-right apply', () => { injected.measureRoom(() => true) injected.splitPane(surface.layout.activePaneId) expect(panes()).toHaveLength(2) + // The fullscreen command follows the automatic fullscreen rule the seats report. + const layoutOf = () => instance.getSnapshot().bySession[SESSION]?.layout injected.toggleFullscreen() + expect(layoutOf()?.mode).toBe('fullscreen') + injected.reportAutoFullscreen(true) + injected.toggleFullscreen() + expect(layoutOf()?.expanded).toBe(false) + expect(layoutOf()?.mode).toBe('push') // A global panel takes the Conversation's place: no Session is on screen. layout.panelInfo.set({ activePanelId: 'plugins' }) expect(() => { ctx.sidebarRight.toggleExpanded() }).toThrow('no session surface is mounted') diff --git a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx index 2819bfbc63..b6e7c85fca 100644 --- a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx @@ -71,9 +71,7 @@ function transition(property = 'transform') { async function mountSeat(viewportWidth = 1440, canShow = true, entryCount = 0, opener = false, keepMounted = false) { const runtime = await SlotTestRuntime.create() runtimes.push(runtime) - const frame = { - openRightbar: vi.fn(), closeRightbar: vi.fn(), panelInfo: runtime.panelInfo, viewportWidth: createSnapshotStore(viewportWidth), - } + const frame = { openRightbar: vi.fn(), closeRightbar: vi.fn(), panelInfo: runtime.panelInfo } const pin = vi.fn<(address: string, signal: AbortSignal) => void>() runtime.ctx.provide('layout', frame as never) runtime.ctx.provide('resources', { pin } as never) @@ -512,6 +510,22 @@ describe('RightbarSeat presentation', () => { expect(h.layout().expanded).toBe(false) }) + it('applies the automatic fullscreen rule of the width it renders at to the fullscreen command', async () => { + const h = await mountSeat() + h.open() + // Narrowed below the automatic fullscreen width, the command closes the panel. + h.view.update({ width: 420, viewportWidth: 700, canShow: false }) + fireEvent.click(element(h.view.container, '[data-sidebar-right-mode]')) + expect(h.layout().expanded).toBe(false) + expect(h.layout().mode).toBe('push') + // Widened again, the command switches the recorded mode. + h.view.update({ width: 420, viewportWidth: 1440, canShow: true }) + act(() => { h.controller.toggleExpanded() }) + fireEvent.click(element(h.view.container, '[data-sidebar-right-mode]')) + expect(h.layout().expanded).toBe(true) + expect(h.layout().mode).toBe('fullscreen') + }) + it('preserves manual fullscreen through narrow and wide viewport changes', async () => { const h = await mountSeat() h.open() diff --git a/packages/client/ui-sidebar/tests/panel-list.client.spec.tsx b/packages/client/ui-sidebar/tests/panel-list.client.spec.tsx index 68123aa63e..5673aa95ad 100644 --- a/packages/client/ui-sidebar/tests/panel-list.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/panel-list.client.spec.tsx @@ -39,7 +39,6 @@ async function bench(collapsed = false) { locale.setLocale('en') const layout = { panelInfo: runtime.panelInfo, - viewportWidth: createSnapshotStore(1440), beginNavigation: vi.fn(() => new AbortController().signal), toggleSidebar: vi.fn(), selectPanel: vi.fn((activePanelId: MainPanelId | null) => { runtime.panelInfo.set({ activePanelId }) }), diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index 98053e81db..aae7d728ac 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -279,7 +279,7 @@ function bench(options: BenchOptions = {}) { selectPanel: vi.fn(), retainMainPanels: vi.fn(), setSidebar: vi.fn(), toggleSidebar: vi.fn(), setViewportWidth: vi.fn(), setRightbar: vi.fn(), openRightbar: vi.fn(), closeRightbar: vi.fn(), - }, () => true, createSnapshotStore({ activePanelId: null }), createSnapshotStore(1440)) + }, () => true, createSnapshotStore({ activePanelId: null })) const selectPanel = vi.spyOn(layout, 'selectPanel') ctx.provide('layout', layout) ctx.effect(() => () => { layout.dispose() }) From 333d7e75d25f9047cbd3f39138d10074645e7106 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:37:20 +0800 Subject: [PATCH 04/42] test(vitest): apply the lane test budget inside every inline project DSH_COVERAGE_TEST_TIMEOUT_MS expanded to --testTimeout, --hookTimeout, and --expect.poll.timeout on the coverage and serial lanes, but Vitest forwards only a fixed list of CLI overrides into inline projects and neither hookTimeout nor expect is on it, so under the root config's two projects the hook and expect.poll budgets stayed at Vitest's defaults on every lane. vitest.config.ts now reads the variable through coverageTestTimeoutOptions and spreads the three budgets into both projects; run-gates and the partition coordinator pass no timeout flags, leaving one owner for the rule. A new spec spawns vitest on the root config with a 200 ms budget and proves all three waits end inside both projects, that unset keeps the defaults, and that a malformed value fails at config load. Fixes #5335 Related to #5325 --- ...7-subagent-teardown-test-budgets.i18n.yaml | 4 +- ...26-09-07-subagent-teardown-test-budgets.md | 2 +- ...09-07-subagent-teardown-test-budgets.zh.md | 2 +- scripts/ci-workflow.spec.ts | 11 +++ scripts/coverage-partitions.spec.ts | 28 +++--- scripts/coverage-partitions.ts | 46 ++++++--- scripts/fixtures/lane-test-budget.fixture.ts | 28 ++++++ scripts/lane-test-budget.spec.ts | 97 +++++++++++++++++++ scripts/run-coverage-partitions.ts | 7 +- scripts/run-gates.spec.ts | 41 +++----- scripts/run-gates.ts | 15 +-- vitest.config.ts | 12 ++- 12 files changed, 220 insertions(+), 73 deletions(-) create mode 100644 scripts/fixtures/lane-test-budget.fixture.ts create mode 100644 scripts/lane-test-budget.spec.ts diff --git a/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.i18n.yaml b/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.i18n.yaml index 10306946dc..bc1ac12bf6 100644 --- a/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.i18n.yaml @@ -9,8 +9,8 @@ en: 5853599207f6b3bb zh: d58b5e8e19d93a4f /agent-note-subagent-teardown-tests-inherit-their-execution-lane-budgets/decision: - en: 721ea003ebaef8c7 - zh: e17be423bc4c7568 + en: 5c60e4926ac090af + zh: e34b73ab1f6358e0 /agent-note-subagent-teardown-tests-inherit-their-execution-lane-budgets/alternatives-considered: en: 3ad30b2574818a61 zh: d323adddd5065ae4 diff --git a/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.md b/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.md index 372c8b3110..aeea3533b6 100644 --- a/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.md +++ b/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.md @@ -12,7 +12,7 @@ The Windows coverage run (run 34085536250, job 101628739668) reports two teardow The [ACP test](../../../../packages/subagent/subagent-acp/tests/subagent-acp.spec.ts) awaits disposal under the execution lane’s test budget, then checks the actual child outcome. Failure cleanup awaits disposal and child completion before removing the private directory. A deferred exit observation proves that disposal cannot finish merely because termination was requested. The production EOF and termination grace periods remain unchanged. -The [Codex test](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) inherits the execution lane’s hook budget. Cleanup captures its contexts, HTTP fixtures, and temporary roots before its first asynchronous wait, so an overdue hook cannot drain resources registered by another test. It preserves context-disposal, server-closure, and directory-removal ordering, waits for every captured disposer, and attempts the remaining cleanup stages after a rejection. Collected errors identify each failing stage or path and retain their causes; cleanup reports them only after all captured resources have been attempted. +The [Codex test](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) inherits the execution lane’s hook budget, which reaches the inline Vitest projects only through `vitest.config.ts` reading `DSH_COVERAGE_TEST_TIMEOUT_MS`: Vitest forwards `--testTimeout` but neither `--hookTimeout` nor `--expect.poll.timeout` into inline projects, so a flag-carried lane budget raises the per-test default alone. Cleanup captures its contexts, HTTP fixtures, and temporary roots before its first asynchronous wait, so an overdue hook cannot drain resources registered by another test. It preserves context-disposal, server-closure, and directory-removal ordering, waits for every captured disposer, and attempts the remaining cleanup stages after a rejection. Collected errors identify each failing stage or path and retain their causes; cleanup reports them only after all captured resources have been attempted. The [native Windows CI decision](../process/2026-08-08-native-windows-pull-request-ci.md) continues to own lane scheduling and budgets. This change only removes conflicting local deadlines and strengthens resource-lifetime assertions; it does not establish a Windows process-kill or filesystem defect. diff --git a/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.zh.md b/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.zh.md index 8539664280..4d90872684 100644 --- a/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.zh.md +++ b/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.zh.md @@ -12,7 +12,7 @@ Windows 覆盖率运行 (run 34085536250, job 101628739668) 为测试和钩子 [ACP 测试](../../../../packages/subagent/subagent-acp/tests/subagent-acp.spec.ts) 在执行通道的测试预算内等待清理完成,然后检查真实子进程的结果。失败清理先等待释放和子进程完成,再删除私有目录。延迟的退出观察证明,清理不能仅因已请求终止而完成。生产环境的 EOF 与终止宽限期保持不变。 -[Codex 测试](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) 继承执行通道的钩子预算。清理在第一次异步等待前取得其上下文、HTTP 夹具和临时根目录,因此超时钩子不能取走其他测试注册的资源。清理保留上下文释放、服务器关闭、目录删除的顺序,等待所有已取得的释放操作,并在拒绝后继续尝试其余清理阶段。收集的错误指出各自失败的阶段或路径并保留原始原因;只有全部已取得资源都尝试清理后才报告错误。 +[Codex 测试](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) 继承执行通道的钩子预算;该预算只通过 `vitest.config.ts` 读取 `DSH_COVERAGE_TEST_TIMEOUT_MS` 才到达内联 Vitest projects:Vitest 只把 `--testTimeout` 转发给内联 project,不转发 `--hookTimeout` 与 `--expect.poll.timeout`,因此以标志传递的通道预算只会提高单测试默认值。清理在第一次异步等待前取得其上下文、HTTP 夹具和临时根目录,因此超时钩子不能取走其他测试注册的资源。清理保留上下文释放、服务器关闭、目录删除的顺序,等待所有已取得的释放操作,并在拒绝后继续尝试其余清理阶段。收集的错误指出各自失败的阶段或路径并保留原始原因;只有全部已取得资源都尝试清理后才报告错误。 [原生 Windows CI 决策](../process/2026-08-08-native-windows-pull-request-ci.zh.md) 继续负责通道调度和预算。本次改动仅移除冲突的局部期限并加强资源生命周期断言;它并不证明 Windows 进程终止或文件系统存在缺陷。 diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index db3315e074..c08565e5bc 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -613,6 +613,17 @@ describe('CI workflow', () => { expect(config).not.toContain("pool: process.platform === 'win32' ? 'threads' : 'forks'") expect(config.match(/pool: 'forks'/g)).toHaveLength(2) }) + + it('applies the lane test budget inside every Vitest project', () => { + // The coverage and serial lanes grant DSH_COVERAGE_TEST_TIMEOUT_MS; Vitest + // forwards only --testTimeout of the three budgets into inline projects, + // so each project reads the budget from the config (behavior pinned by + // scripts/lane-test-budget.spec.ts). + const config = readFileSync(resolve(root, 'vitest.config.ts'), 'utf8') + + expect(config).toContain('const laneTestBudget = coverageTestTimeoutOptions(process.env[COVERAGE_TEST_TIMEOUT_ENV])') + expect(config.match(/^ {10}\.\.\.laneTestBudget,$/gm)).toHaveLength(2) + }) }) describe('Runtime and LLM e2e Blacksmith routing', () => { diff --git a/scripts/coverage-partitions.spec.ts b/scripts/coverage-partitions.spec.ts index 8d3219b81f..2060608248 100644 --- a/scripts/coverage-partitions.spec.ts +++ b/scripts/coverage-partitions.spec.ts @@ -11,9 +11,10 @@ import { CoveragePartitionCoordinator, assignWeightedPartitions, collectPartitionDurations, - coverageTestTimeoutArgs, + coverageTestTimeoutOptions, forwardedCoverageArgs, parseCoveragePartitionCount, + parseCoverageTestTimeout, parseListOutput, readFileDurations, writeFileDurations, @@ -102,21 +103,24 @@ describe('coverage partition count', () => { }) }) -describe('coverage partition timeout', () => { - it('applies one configured timeout to tests, polling, and hooks', () => { - expect(coverageTestTimeoutArgs('30000')).toEqual([ - '--testTimeout=30000', - '--expect.poll.timeout=30000', - '--hookTimeout=30000', - ]) +describe('lane test budget', () => { + it('applies one configured budget to tests, hooks, and expect.poll', () => { + expect(coverageTestTimeoutOptions('30000')).toEqual({ + testTimeout: 30000, + hookTimeout: 30000, + expect: { poll: { timeout: 30000 } }, + }) }) - it('keeps Vitest defaults when the timeout is absent', () => { - expect(coverageTestTimeoutArgs(undefined)).toEqual([]) + it.each([undefined, ''])('keeps Vitest defaults when the budget is %j', (raw) => { + expect(parseCoverageTestTimeout(raw)).toBeUndefined() + expect(coverageTestTimeoutOptions(raw)).toEqual({}) }) - it('rejects invalid timeout input', () => { - expect(() => coverageTestTimeoutArgs('0')) + it.each(['0', '-1', '2.5', '090', '90000ms', 'many'])('rejects %j', (raw) => { + expect(() => parseCoverageTestTimeout(raw)) + .toThrow(`${COVERAGE_TEST_TIMEOUT_ENV} must be a positive integer, got ${JSON.stringify(raw)}.`) + expect(() => coverageTestTimeoutOptions(raw)) .toThrow(`${COVERAGE_TEST_TIMEOUT_ENV} must be a positive integer`) }) }) diff --git a/scripts/coverage-partitions.ts b/scripts/coverage-partitions.ts index 1dbc89390a..fcfc31b78d 100644 --- a/scripts/coverage-partitions.ts +++ b/scripts/coverage-partitions.ts @@ -12,7 +12,7 @@ export const COVERAGE_PARTITIONS_ENV = 'DSH_COVERAGE_PARTITIONS' /** Internal marker that suppresses reports and thresholds inside a partition process. */ export const COVERAGE_PARTITION_MODE_ENV = 'DSH_COVERAGE_PARTITION_MODE' -/** Environment variable overriding instrumented test, polling, and hook timeouts. */ +/** Environment variable carrying a lane's per-test, hook, and `expect.poll` budget in milliseconds. */ export const COVERAGE_TEST_TIMEOUT_ENV = 'DSH_COVERAGE_TEST_TIMEOUT_MS' /** @@ -85,22 +85,46 @@ export function parseCoveragePartitionCount(raw: string | undefined): number | u } /** - * Resolve the paired Vitest timeout arguments used by coverage partitions. - * `--hookTimeout` travels with the test budget because setup and teardown pay - * the same host contention the raised test budget accounts for: fixtures that - * await child exit or retry Windows handle release spend that cost in - * `afterEach`, where Vitest's separate 10 s default would otherwise fail a - * suite whose cases all passed. + * Parse the lane test budget carried by `DSH_COVERAGE_TEST_TIMEOUT_MS`. * @param raw - the configured millisecond budget, or undefined to keep Vitest's defaults. - * @returns the Vitest arguments applying that budget, empty when unset. + * @returns the budget in milliseconds, or undefined when unset. + * @throws when the value is set and is not a positive integer. */ -export function coverageTestTimeoutArgs(raw: string | undefined): string[] { - if (raw === undefined || raw === '') return [] +export function parseCoverageTestTimeout(raw: string | undefined): number | undefined { + if (raw === undefined || raw === '') return undefined const parsed = Number.parseInt(raw, 10) if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) { throw new Error(`${COVERAGE_TEST_TIMEOUT_ENV} must be a positive integer, got ${JSON.stringify(raw)}.`) } - return [`--testTimeout=${raw}`, `--expect.poll.timeout=${raw}`, `--hookTimeout=${raw}`] + return parsed +} + +/** The Vitest `test` options one lane budget sets in each inline project. */ +export interface CoverageTestTimeoutOptions { + testTimeout?: number + hookTimeout?: number + expect?: { poll: { timeout: number } } +} + +/** + * Resolve the Vitest test options that apply one lane budget to the per-test, + * hook, and `expect.poll` defaults together. The hook budget travels with the + * test budget because setup and teardown pay the same host contention the + * raised test budget accounts for: fixtures that await child exit or retry + * Windows handle release spend that cost in `afterEach`, where Vitest's + * separate 10 s default would otherwise fail a suite whose cases all passed. + * The root config spreads these into each inline project: Vitest forwards + * only a fixed list of CLI overrides into projects, and `--hookTimeout` and + * `--expect.poll.timeout` are not on it, so a flag-based budget reaches only + * the per-test default. Explicit `describe`, case, and fixture timeouts remain + * authoritative. + * @param raw - the configured millisecond budget, or undefined to keep Vitest's defaults. + * @returns the options to spread into a project's `test` block, empty when unset. + */ +export function coverageTestTimeoutOptions(raw: string | undefined): CoverageTestTimeoutOptions { + const timeout = parseCoverageTestTimeout(raw) + if (timeout === undefined) return {} + return { testTimeout: timeout, hookTimeout: timeout, expect: { poll: { timeout } } } } /** Remove pnpm's package-script separator before forwarding Vitest arguments. */ diff --git a/scripts/fixtures/lane-test-budget.fixture.ts b/scripts/fixtures/lane-test-budget.fixture.ts new file mode 100644 index 0000000000..0dff9600ca --- /dev/null +++ b/scripts/fixtures/lane-test-budget.fixture.ts @@ -0,0 +1,28 @@ +/** + * Waits that end only when a lane budget below them is in force. Runs only + * inside the Vitest child that scripts/lane-test-budget.spec.ts spawns. + */ +import { beforeEach, describe, expect, it } from 'vitest' + +const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)) + +describe('lane test budget fixture', () => { + it('per-test budget', async () => { + await sleep(600) + }) + + // Its own case budget stays above the wait so the poll budget, not the + // per-test budget, is what ends the assertion. + it('expect.poll budget', { timeout: 5_000 }, async () => { + const start = Date.now() + await expect.poll(() => Date.now() - start).toBeGreaterThan(500) + }) + + describe('hook budget', () => { + beforeEach(async () => { + await sleep(600) + }) + + it('runs after the hook', () => {}) + }) +}) diff --git a/scripts/lane-test-budget.spec.ts b/scripts/lane-test-budget.spec.ts new file mode 100644 index 0000000000..582698a17b --- /dev/null +++ b/scripts/lane-test-budget.spec.ts @@ -0,0 +1,97 @@ +/** + * DSH_COVERAGE_TEST_TIMEOUT_MS reaches every inline Vitest project. The root + * config spreads the budget into each project because Vitest forwards only a + * fixed list of CLI overrides into projects: --testTimeout is on it, + * --hookTimeout and --expect.poll.timeout are not, so a flag-based budget + * raises only the per-test default. A budget below the fixture's waits must + * end all three inside both projects; unset must keep Vitest's defaults, under + * which the same fixture passes. + */ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, describe, expect, it } from 'vitest' +import { COVERAGE_TEST_TIMEOUT_ENV } from './coverage-partitions.ts' + +const root = resolve(import.meta.dirname, '..') +const vitestCli = fileURLToPath(new URL('../node_modules/vitest/vitest.mjs', import.meta.url)) +const fixture = 'scripts/fixtures/lane-test-budget.fixture.ts' + +// The root config with every project's inventory narrowed to the fixture, so +// the fixture never joins the ordinary inventory and still runs once per +// project. Absolute import in POSIX spelling because the temporary directory +// is outside the repository; the sibling package.json keeps Vite bundling the +// config as ESM, which the repository root's "type" otherwise supplies. +const temporaryRoot = mkdtempSync(join(tmpdir(), 'dsh-lane-test-budget-')) +const configPath = join(temporaryRoot, 'vitest.config.ts') +writeFileSync(join(temporaryRoot, 'package.json'), '{ "type": "module" }\n', 'utf8') +writeFileSync(configPath, [ + `import base from ${JSON.stringify(resolve(root, 'vitest.config.ts').split('\\').join('/'))}`, + 'export default {', + ' ...base,', + ' test: {', + ' ...base.test,', + ' projects: (base.test.projects ?? []).map(project => ({', + ' ...project,', + ` test: { ...project.test, include: [${JSON.stringify(fixture)}] },`, + ' })),', + ' },', + '}', + '', +].join('\n'), 'utf8') +afterAll(() => { rmSync(temporaryRoot, { recursive: true, force: true }) }) + +function runFixture(budget: string | undefined): { status: number | null; output: string } { + const env: NodeJS.ProcessEnv = { ...process.env, NO_COLOR: '1' } + for (const name of Object.keys(env)) { + // The parent worker's Vitest state and the coverage coordinator's own + // variables describe this process, not the child; the Actions reporter + // would otherwise annotate the parent job with the fixture's failures. + if (name.startsWith('VITEST') || name.startsWith('DSH_COVERAGE_') || name === 'GITHUB_ACTIONS') Reflect.deleteProperty(env, name) + } + if (budget !== undefined) env[COVERAGE_TEST_TIMEOUT_ENV] = budget + const child = spawnSync(process.execPath, [vitestCli, 'run', '--config', configPath], { + cwd: root, + env, + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + }) + if (child.error !== undefined) throw child.error + return { status: child.status, output: `${child.stdout}\n${child.stderr}` } +} + +describe('lane test budget', () => { + it('ends per-test, hook, and expect.poll waits in both inline projects', { timeout: 90_000 }, () => { + const { status, output } = runFixture('200') + + expect(output).toMatch(/\|thread-safe\| scripts\/fixtures\/lane-test-budget\.fixture\.ts/) + expect(output).toMatch(/\|process-bound\| scripts\/fixtures\/lane-test-budget\.fixture\.ts/) + expect(output).toMatch(/Test Files\s+2 failed \(2\)/) + expect(output).toMatch(/Tests\s+6 failed \(6\)/) + expect(output.match(/Test timed out in 200ms\./g)).toHaveLength(2) + expect(output.match(/Hook timed out in 200ms\./g)).toHaveLength(2) + expect(output.match(/Matcher did not succeed in time\./g)).toHaveLength(2) + for (const [, polled] of output.matchAll(/expected (\d+) to be greater than 500/g)) { + expect(Number(polled)).toBeLessThan(500) + } + expect(status).toBe(1) + }) + + it('keeps Vitest defaults when the budget is unset', { timeout: 90_000 }, () => { + const { status, output } = runFixture(undefined) + + expect(output).toMatch(/Test Files\s+2 passed \(2\)/) + expect(output).toMatch(/Tests\s+6 passed \(6\)/) + expect(status).toBe(0) + }) + + it('refuses a malformed budget at config load before any test runs', { timeout: 90_000 }, () => { + const { status, output } = runFixture('90000ms') + + expect(output).toContain(`${COVERAGE_TEST_TIMEOUT_ENV} must be a positive integer, got "90000ms".`) + expect(output).not.toMatch(/lane-test-budget\.fixture\.ts \(/) + expect(status).toBe(1) + }) +}) diff --git a/scripts/run-coverage-partitions.ts b/scripts/run-coverage-partitions.ts index 8626665b96..4b2293c148 100644 --- a/scripts/run-coverage-partitions.ts +++ b/scripts/run-coverage-partitions.ts @@ -2,9 +2,7 @@ import { resolve } from 'node:path' import { COVERAGE_PARTITIONS_ENV, - COVERAGE_TEST_TIMEOUT_ENV, CoveragePartitionCoordinator, - coverageTestTimeoutArgs, forwardedCoverageArgs, parseCoveragePartitionCount, } from './coverage-partitions.ts' @@ -22,9 +20,6 @@ const coordinator = new CoveragePartitionCoordinator({ root: resolve(import.meta.dirname, '..'), partitions, pnpmEntrypoint, - vitestArgs: [ - ...coverageTestTimeoutArgs(process.env[COVERAGE_TEST_TIMEOUT_ENV]), - ...forwardedCoverageArgs(process.argv.slice(2)), - ], + vitestArgs: forwardedCoverageArgs(process.argv.slice(2)), }) process.exitCode = await coordinator.run() diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 9d7a929446..ae31a7cc27 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -518,36 +518,23 @@ describe('gate graph validation', () => { expect(results.some(result => result.status === 'skipped')).toBe(false) }) - it('applies one configured test, polling, and hook timeout to both coverage gates', () => { - const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', '15000', () => - withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))) - - for (const id of ['coverage', 'coverage-exempt-heavy']) { - expect(gates.find(subject => subject.id === id)?.args).toEqual(expect.arrayContaining([ - '--testTimeout=15000', - '--expect.poll.timeout=15000', - '--hookTimeout=15000', - ])) + it('leaves the lane test budget to the inherited environment on both coverage gates', () => { + // vitest.config.ts reads DSH_COVERAGE_TEST_TIMEOUT_MS per inline project; + // a CLI flag here would reach only the per-test default and split the + // budget between two owners. + for (const budget of ['15000', undefined]) { + const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', budget, () => + withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))) + for (const id of ['coverage', 'coverage-exempt-heavy']) { + const gate = gates.find(subject => subject.id === id) + expect(gate?.args).not.toEqual(expect.arrayContaining([ + expect.stringMatching(/^--(?:testTimeout|expect\.poll\.timeout|hookTimeout)=/), + ])) + expect(gate?.env ?? {}).not.toHaveProperty('DSH_COVERAGE_TEST_TIMEOUT_MS') + } } }) - it('keeps Vitest timeout defaults when the coverage override is absent', () => { - const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', undefined, () => - withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))) - - for (const id of ['coverage', 'coverage-exempt-heavy']) { - expect(gates.find(subject => subject.id === id)?.args).not.toEqual(expect.arrayContaining([ - expect.stringMatching(/^--(?:testTimeout|expect\.poll\.timeout|hookTimeout)=/), - ])) - } - }) - - it('rejects an invalid coverage timeout before starting a gate', () => { - expect(() => withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', '0', () => - withPnpmEntrypoint(() => gatesForMode('ci-windows-complete')))) - .toThrow('DSH_COVERAGE_TEST_TIMEOUT_MS must be a positive integer') - }) - it('selects partitioned coverage only when explicitly configured', () => { const coverage = withEnv('DSH_COVERAGE_PARTITIONS', '3', () => withPnpmEntrypoint(() => gatesForMode('ci-windows-complete').find(subject => subject.id === 'coverage'))) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index d0c4773c66..71ef2e3009 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -12,12 +12,7 @@ import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' import { CLIENT_BUILD_PROFILE_SELECTOR } from './client-build-environment.ts' import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './coverage-exempt.ts' -import { - COVERAGE_PARTITIONS_ENV, - COVERAGE_TEST_TIMEOUT_ENV, - coverageTestTimeoutArgs, - parseCoveragePartitionCount, -} from './coverage-partitions.ts' +import { COVERAGE_PARTITIONS_ENV, parseCoveragePartitionCount } from './coverage-partitions.ts' import { pnpmInvocation } from './pnpm-invocation.ts' /** A named aggregate exposed by the gate runner. */ @@ -653,9 +648,8 @@ function lintGate(options: { needs?: string[] } = {}): Gate { // small share. A budget of 1 gives each gate 1 worker; lanes that need a strict // total of one (the serial reference jobs) also set DSH_GATE_CONCURRENCY=1, // which keeps the gates from overlapping at all. -// DSH_COVERAGE_TEST_TIMEOUT_MS raises Vitest's per-test, expect.poll, and hook -// defaults together for instrumented lanes whose scheduling overhead exceeds -// those defaults. Explicit fixture timeouts remain authoritative. +// DSH_COVERAGE_TEST_TIMEOUT_MS reaches both gates through the inherited +// environment: vitest.config.ts applies it to every inline project. function coverageWorkerArgs(): { instrumented: string[]; exempt: string[] } { const [flag] = positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers') if (flag === undefined) return { instrumented: [], exempt: [] } @@ -670,7 +664,6 @@ function coverageWorkerArgs(): { instrumented: string[]; exempt: string[] } { function coverageGates(): Gate[] { const workers = coverageWorkerArgs() - const timeouts = coverageTestTimeoutArgs(process.env[COVERAGE_TEST_TIMEOUT_ENV]) const partitions = parseCoveragePartitionCount(process.env[COVERAGE_PARTITIONS_ENV]) const instrumented = partitions === undefined ? pnpmExec('coverage', [ @@ -678,7 +671,6 @@ function coverageGates(): Gate[] { 'run', '--coverage', ...workers.instrumented, - ...timeouts, ], { label: 'test:coverage', env: { [COVERAGE_EXEMPT_ENV]: '1' }, @@ -697,7 +689,6 @@ function coverageGates(): Gate[] { 'run', ...coverageExemptHeavySuites.map(suite => suite.filter), ...workers.exempt, - ...timeouts, ], { label: 'test:coverage-exempt-heavy', needs: ['native-system'], diff --git a/vitest.config.ts b/vitest.config.ts index 6c0a46e4f3..46c19df6bb 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,7 @@ import { resolvePwshPath } from './packages/shell/pwsh-local/src/resolve.ts' import { defineConfig } from 'vitest/config' import { standardDecoratorPlugin, vitestExecArgv } from './vitest.shared.ts' import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './scripts/coverage-exempt.ts' -import { COVERAGE_PARTITION_MODE_ENV } from './scripts/coverage-partitions.ts' +import { COVERAGE_PARTITION_MODE_ENV, COVERAGE_TEST_TIMEOUT_ENV, coverageTestTimeoutOptions } from './scripts/coverage-partitions.ts' // Prints exact `path:line:col` records for every uncovered statement, branch // path, and function when a file misses the per-file 100% gate — the built-in @@ -144,6 +144,14 @@ if (coveragePartitionRaw !== undefined && coveragePartitionRaw !== '' && coverag } const coveragePartitionMode = coveragePartitionRaw === '1' +// Lanes on shared hosts raise the per-test, hook, and expect.poll defaults +// together through DSH_COVERAGE_TEST_TIMEOUT_MS. The budget lands in each +// inline project below because Vitest forwards only a fixed list of CLI +// overrides into projects: --testTimeout is on it, --hookTimeout and +// --expect.poll.timeout are not. Unset keeps Vitest's defaults; explicit +// describe, case, and fixture timeouts remain authoritative. +const laneTestBudget = coverageTestTimeoutOptions(process.env[COVERAGE_TEST_TIMEOUT_ENV]) + // These suites exercise process-global state, process APIs, or timing-sensitive process I/O // that worker threads cannot isolate reliably under aggregate gate contention. // Keep the narrow exception in forks while the rest of the inventory avoids per-file processes. @@ -177,6 +185,7 @@ export default defineConfig({ // MaybeLocal in cjs_lexer::Parse) from worker threads on macOS, // Linux, and Windows. Forked workers avoid that shared thread path. pool: 'forks', + ...laneTestBudget, setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-invariants.ts', './scripts/test-dom-environment.ts'], include: testIncludes, exclude: [ @@ -192,6 +201,7 @@ export default defineConfig({ name: 'process-bound', execArgv: vitestExecArgv, pool: 'forks', + ...laneTestBudget, setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-invariants.ts', './scripts/test-dom-environment.ts'], include: processBoundTests, exclude: [ From 3e38e2118c18584feceb8de3a42aafee1385f76a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:18:03 +0800 Subject: [PATCH 05/42] test(vitest): make the lane-budget regression load-independent and own its resources The fixture's poll case counts attempts instead of reading a clock, so a host stall cannot turn a budget-ended poll into a pass; the control case reads the poll's reported duration, which the 1000 ms default bounds from below. The spawning spec acquires its temporary config in beforeAll, bounds the child with a timeout and asserts no signal ended it, and counts the polled values it inspects. CoverageTestTimeoutOptions is Vitest's own TestUserConfig members; the rule's reach has one home in its JSDoc, which the config, gate, spec, and note comments reference. --- ...7-subagent-teardown-test-budgets.i18n.yaml | 4 +- ...26-09-07-subagent-teardown-test-budgets.md | 2 +- ...09-07-subagent-teardown-test-budgets.zh.md | 2 +- scripts/ci-workflow.spec.ts | 7 +- scripts/coverage-partitions.ts | 19 ++-- scripts/fixtures/lane-test-budget.fixture.ts | 16 ++-- scripts/lane-test-budget.spec.ts | 95 ++++++++++++------- scripts/run-gates.spec.ts | 10 +- scripts/run-gates.ts | 4 +- vitest.config.ts | 8 +- 10 files changed, 96 insertions(+), 71 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.i18n.yaml b/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.i18n.yaml index bc1ac12bf6..bd3c2101c7 100644 --- a/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.i18n.yaml @@ -9,8 +9,8 @@ en: 5853599207f6b3bb zh: d58b5e8e19d93a4f /agent-note-subagent-teardown-tests-inherit-their-execution-lane-budgets/decision: - en: 5c60e4926ac090af - zh: e34b73ab1f6358e0 + en: f2f641fe1f271edf + zh: 321df1f4ea9e783d /agent-note-subagent-teardown-tests-inherit-their-execution-lane-budgets/alternatives-considered: en: 3ad30b2574818a61 zh: d323adddd5065ae4 diff --git a/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.md b/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.md index aeea3533b6..5cb6e4662a 100644 --- a/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.md +++ b/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.md @@ -12,7 +12,7 @@ The Windows coverage run (run 34085536250, job 101628739668) reports two teardow The [ACP test](../../../../packages/subagent/subagent-acp/tests/subagent-acp.spec.ts) awaits disposal under the execution lane’s test budget, then checks the actual child outcome. Failure cleanup awaits disposal and child completion before removing the private directory. A deferred exit observation proves that disposal cannot finish merely because termination was requested. The production EOF and termination grace periods remain unchanged. -The [Codex test](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) inherits the execution lane’s hook budget, which reaches the inline Vitest projects only through `vitest.config.ts` reading `DSH_COVERAGE_TEST_TIMEOUT_MS`: Vitest forwards `--testTimeout` but neither `--hookTimeout` nor `--expect.poll.timeout` into inline projects, so a flag-carried lane budget raises the per-test default alone. Cleanup captures its contexts, HTTP fixtures, and temporary roots before its first asynchronous wait, so an overdue hook cannot drain resources registered by another test. It preserves context-disposal, server-closure, and directory-removal ordering, waits for every captured disposer, and attempts the remaining cleanup stages after a rejection. Collected errors identify each failing stage or path and retain their causes; cleanup reports them only after all captured resources have been attempted. +The [Codex test](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) inherits the execution lane’s hook budget, which `vitest.config.ts` applies to every inline project from `DSH_COVERAGE_TEST_TIMEOUT_MS` ([`coverageTestTimeoutOptions`](../../../../scripts/coverage-partitions.ts) owns why a CLI flag cannot carry it). Cleanup captures its contexts, HTTP fixtures, and temporary roots before its first asynchronous wait, so an overdue hook cannot drain resources registered by another test. It preserves context-disposal, server-closure, and directory-removal ordering, waits for every captured disposer, and attempts the remaining cleanup stages after a rejection. Collected errors identify each failing stage or path and retain their causes; cleanup reports them only after all captured resources have been attempted. The [native Windows CI decision](../process/2026-08-08-native-windows-pull-request-ci.md) continues to own lane scheduling and budgets. This change only removes conflicting local deadlines and strengthens resource-lifetime assertions; it does not establish a Windows process-kill or filesystem defect. diff --git a/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.zh.md b/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.zh.md index 4d90872684..66a1cc0924 100644 --- a/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.zh.md +++ b/.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.zh.md @@ -12,7 +12,7 @@ Windows 覆盖率运行 (run 34085536250, job 101628739668) 为测试和钩子 [ACP 测试](../../../../packages/subagent/subagent-acp/tests/subagent-acp.spec.ts) 在执行通道的测试预算内等待清理完成,然后检查真实子进程的结果。失败清理先等待释放和子进程完成,再删除私有目录。延迟的退出观察证明,清理不能仅因已请求终止而完成。生产环境的 EOF 与终止宽限期保持不变。 -[Codex 测试](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) 继承执行通道的钩子预算;该预算只通过 `vitest.config.ts` 读取 `DSH_COVERAGE_TEST_TIMEOUT_MS` 才到达内联 Vitest projects:Vitest 只把 `--testTimeout` 转发给内联 project,不转发 `--hookTimeout` 与 `--expect.poll.timeout`,因此以标志传递的通道预算只会提高单测试默认值。清理在第一次异步等待前取得其上下文、HTTP 夹具和临时根目录,因此超时钩子不能取走其他测试注册的资源。清理保留上下文释放、服务器关闭、目录删除的顺序,等待所有已取得的释放操作,并在拒绝后继续尝试其余清理阶段。收集的错误指出各自失败的阶段或路径并保留原始原因;只有全部已取得资源都尝试清理后才报告错误。 +[Codex 测试](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) 继承执行通道的钩子预算;`vitest.config.ts` 从 `DSH_COVERAGE_TEST_TIMEOUT_MS` 把该预算施加到每个内联 project([`coverageTestTimeoutOptions`](../../../../scripts/coverage-partitions.ts) 负责说明 CLI 标志为何无法承载它)。清理在第一次异步等待前取得其上下文、HTTP 夹具和临时根目录,因此超时钩子不能取走其他测试注册的资源。清理保留上下文释放、服务器关闭、目录删除的顺序,等待所有已取得的释放操作,并在拒绝后继续尝试其余清理阶段。收集的错误指出各自失败的阶段或路径并保留原始原因;只有全部已取得资源都尝试清理后才报告错误。 [原生 Windows CI 决策](../process/2026-08-08-native-windows-pull-request-ci.zh.md) 继续负责通道调度和预算。本次改动仅移除冲突的局部期限并加强资源生命周期断言;它并不证明 Windows 进程终止或文件系统存在缺陷。 diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index c08565e5bc..0a2a1af772 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -615,10 +615,9 @@ describe('CI workflow', () => { }) it('applies the lane test budget inside every Vitest project', () => { - // The coverage and serial lanes grant DSH_COVERAGE_TEST_TIMEOUT_MS; Vitest - // forwards only --testTimeout of the three budgets into inline projects, - // so each project reads the budget from the config (behavior pinned by - // scripts/lane-test-budget.spec.ts). + // Each inline project spreads coverageTestTimeoutOptions, the only route + // for DSH_COVERAGE_TEST_TIMEOUT_MS into projects; the behavior itself is + // pinned by scripts/lane-test-budget.spec.ts. const config = readFileSync(resolve(root, 'vitest.config.ts'), 'utf8') expect(config).toContain('const laneTestBudget = coverageTestTimeoutOptions(process.env[COVERAGE_TEST_TIMEOUT_ENV])') diff --git a/scripts/coverage-partitions.ts b/scripts/coverage-partitions.ts index fcfc31b78d..d67bc5b533 100644 --- a/scripts/coverage-partitions.ts +++ b/scripts/coverage-partitions.ts @@ -3,6 +3,7 @@ import { spawn } from 'node:child_process' import { globSync, readFileSync, writeFileSync } from 'node:fs' import { lstat, mkdir, readdir, rm, unlink, writeFile } from 'node:fs/promises' import { join, relative, sep } from 'node:path' +import type { TestUserConfig } from 'vitest/config' import { coverageExemptHeavySuites } from './coverage-exempt.ts' import { pnpmInvocation } from './pnpm-invocation.ts' @@ -100,11 +101,7 @@ export function parseCoverageTestTimeout(raw: string | undefined): number | unde } /** The Vitest `test` options one lane budget sets in each inline project. */ -export interface CoverageTestTimeoutOptions { - testTimeout?: number - hookTimeout?: number - expect?: { poll: { timeout: number } } -} +export type CoverageTestTimeoutOptions = Pick /** * Resolve the Vitest test options that apply one lane budget to the per-test, @@ -113,11 +110,13 @@ export interface CoverageTestTimeoutOptions { * raised test budget accounts for: fixtures that await child exit or retry * Windows handle release spend that cost in `afterEach`, where Vitest's * separate 10 s default would otherwise fail a suite whose cases all passed. - * The root config spreads these into each inline project: Vitest forwards - * only a fixed list of CLI overrides into projects, and `--hookTimeout` and - * `--expect.poll.timeout` are not on it, so a flag-based budget reaches only - * the per-test default. Explicit `describe`, case, and fixture timeouts remain - * authoritative. + * The root `vitest.config.ts` spreads these into each inline project, so the + * budget governs every Vitest run of that config in an environment carrying + * the variable, whichever gate or script starts it. The config, not a CLI + * flag, is the only route into inline projects: Vitest forwards a fixed list + * of CLI overrides into projects, and `--hookTimeout` and + * `--expect.poll.timeout` are not on it (only `--testTimeout` is). Explicit + * `describe`, case, and fixture timeouts remain authoritative. * @param raw - the configured millisecond budget, or undefined to keep Vitest's defaults. * @returns the options to spread into a project's `test` block, empty when unset. */ diff --git a/scripts/fixtures/lane-test-budget.fixture.ts b/scripts/fixtures/lane-test-budget.fixture.ts index 0dff9600ca..0f9bc64a85 100644 --- a/scripts/fixtures/lane-test-budget.fixture.ts +++ b/scripts/fixtures/lane-test-budget.fixture.ts @@ -1,6 +1,6 @@ /** - * Waits that end only when a lane budget below them is in force. Runs only - * inside the Vitest child that scripts/lane-test-budget.spec.ts spawns. + * Waits that end only through the lane budget in force. Runs only inside the + * Vitest child that scripts/lane-test-budget.spec.ts spawns. */ import { beforeEach, describe, expect, it } from 'vitest' @@ -11,11 +11,15 @@ describe('lane test budget fixture', () => { await sleep(600) }) - // Its own case budget stays above the wait so the poll budget, not the - // per-test budget, is what ends the assertion. + // The predicate counts attempts instead of reading a clock, so a host stall + // cannot turn a budget-ended poll into a pass: a 10 ms interval fits at most + // ~20 attempts into a 200 ms budget and ~100 into Vitest's 1000 ms default, + // both far below the target, and the case's own budget stays above both so + // the poll budget is what ends the assertion. The reported duration is then + // the poll budget (or longer under load), which the spawning spec reads. it('expect.poll budget', { timeout: 5_000 }, async () => { - const start = Date.now() - await expect.poll(() => Date.now() - start).toBeGreaterThan(500) + let attempts = 0 + await expect.poll(() => { attempts += 1; return attempts }, { interval: 10 }).toBeGreaterThan(10_000) }) describe('hook budget', () => { diff --git a/scripts/lane-test-budget.spec.ts b/scripts/lane-test-budget.spec.ts index 582698a17b..b6250a50de 100644 --- a/scripts/lane-test-budget.spec.ts +++ b/scripts/lane-test-budget.spec.ts @@ -1,47 +1,57 @@ /** - * DSH_COVERAGE_TEST_TIMEOUT_MS reaches every inline Vitest project. The root - * config spreads the budget into each project because Vitest forwards only a - * fixed list of CLI overrides into projects: --testTimeout is on it, - * --hookTimeout and --expect.poll.timeout are not, so a flag-based budget - * raises only the per-test default. A budget below the fixture's waits must - * end all three inside both projects; unset must keep Vitest's defaults, under - * which the same fixture passes. + * DSH_COVERAGE_TEST_TIMEOUT_MS reaches every inline Vitest project through the + * root config (coverageTestTimeoutOptions owns the rule and why a CLI flag + * cannot carry it). A budget below the fixture's waits must end all three + * inside both projects; unset must keep Vitest's defaults, under which the + * fixture's per-test and hook waits pass and its poll ends at the 1000 ms + * default; a malformed value must fail at config load. */ import { spawnSync } from 'node:child_process' import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { afterAll, describe, expect, it } from 'vitest' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { COVERAGE_TEST_TIMEOUT_ENV } from './coverage-partitions.ts' const root = resolve(import.meta.dirname, '..') const vitestCli = fileURLToPath(new URL('../node_modules/vitest/vitest.mjs', import.meta.url)) const fixture = 'scripts/fixtures/lane-test-budget.fixture.ts' +const fixtureLine = /lane-test-budget\.fixture\.ts \((\d+) tests(?: \| (\d+) failed)?\)/g + +/** One spawned Vitest run takes a few seconds; a hung child must not hold the worker. */ +const CHILD_TIMEOUT_MS = 60_000 + +let temporaryRoot: string | undefined +let configPath = '' // The root config with every project's inventory narrowed to the fixture, so // the fixture never joins the ordinary inventory and still runs once per // project. Absolute import in POSIX spelling because the temporary directory // is outside the repository; the sibling package.json keeps Vite bundling the // config as ESM, which the repository root's "type" otherwise supplies. -const temporaryRoot = mkdtempSync(join(tmpdir(), 'dsh-lane-test-budget-')) -const configPath = join(temporaryRoot, 'vitest.config.ts') -writeFileSync(join(temporaryRoot, 'package.json'), '{ "type": "module" }\n', 'utf8') -writeFileSync(configPath, [ - `import base from ${JSON.stringify(resolve(root, 'vitest.config.ts').split('\\').join('/'))}`, - 'export default {', - ' ...base,', - ' test: {', - ' ...base.test,', - ' projects: (base.test.projects ?? []).map(project => ({', - ' ...project,', - ` test: { ...project.test, include: [${JSON.stringify(fixture)}] },`, - ' })),', - ' },', - '}', - '', -].join('\n'), 'utf8') -afterAll(() => { rmSync(temporaryRoot, { recursive: true, force: true }) }) +beforeAll(() => { + temporaryRoot = mkdtempSync(join(tmpdir(), 'dsh-lane-test-budget-')) + configPath = join(temporaryRoot, 'vitest.config.ts') + writeFileSync(join(temporaryRoot, 'package.json'), '{ "type": "module" }\n', 'utf8') + writeFileSync(configPath, [ + `import base from ${JSON.stringify(resolve(root, 'vitest.config.ts').split('\\').join('/'))}`, + 'export default {', + ' ...base,', + ' test: {', + ' ...base.test,', + ' projects: (base.test.projects ?? []).map(project => ({', + ' ...project,', + ` test: { ...project.test, include: [${JSON.stringify(fixture)}] },`, + ' })),', + ' },', + '}', + '', + ].join('\n'), 'utf8') +}) +afterAll(() => { + if (temporaryRoot !== undefined) rmSync(temporaryRoot, { recursive: true, force: true }) +}) function runFixture(budget: string | undefined): { status: number | null; output: string } { const env: NodeJS.ProcessEnv = { ...process.env, NO_COLOR: '1' } @@ -57,41 +67,56 @@ function runFixture(budget: string | undefined): { status: number | null; output env, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, + timeout: CHILD_TIMEOUT_MS, + killSignal: 'SIGKILL', }) if (child.error !== undefined) throw child.error + expect(child.signal, 'the child Vitest run ended through a signal').toBeNull() return { status: child.status, output: `${child.stdout}\n${child.stderr}` } } +/** Per-project fixture summary lines, `[total, failed]` each, in output order. */ +function fixtureSummaries(output: string): Array<[number, number]> { + return [...output.matchAll(fixtureLine)].map(([, total, failed]) => [Number(total), Number(failed ?? 0)]) +} + describe('lane test budget', () => { it('ends per-test, hook, and expect.poll waits in both inline projects', { timeout: 90_000 }, () => { const { status, output } = runFixture('200') expect(output).toMatch(/\|thread-safe\| scripts\/fixtures\/lane-test-budget\.fixture\.ts/) expect(output).toMatch(/\|process-bound\| scripts\/fixtures\/lane-test-budget\.fixture\.ts/) - expect(output).toMatch(/Test Files\s+2 failed \(2\)/) - expect(output).toMatch(/Tests\s+6 failed \(6\)/) + expect(fixtureSummaries(output)).toEqual([[3, 3], [3, 3]]) expect(output.match(/Test timed out in 200ms\./g)).toHaveLength(2) expect(output.match(/Hook timed out in 200ms\./g)).toHaveLength(2) expect(output.match(/Matcher did not succeed in time\./g)).toHaveLength(2) - for (const [, polled] of output.matchAll(/expected (\d+) to be greater than 500/g)) { - expect(Number(polled)).toBeLessThan(500) - } + // At most ~20 attempts fit into the 200 ms budget; the target is 10 000. + const polled = [...output.matchAll(/expected (\d+) to be greater than 10000/g)].map(([, count]) => Number(count)) + expect(polled).toHaveLength(2) + for (const count of polled) expect(count).toBeLessThan(100) expect(status).toBe(1) }) it('keeps Vitest defaults when the budget is unset', { timeout: 90_000 }, () => { const { status, output } = runFixture(undefined) - expect(output).toMatch(/Test Files\s+2 passed \(2\)/) - expect(output).toMatch(/Tests\s+6 passed \(6\)/) - expect(status).toBe(0) + // 600 ms waits pass under the 5000 ms per-test and 10 000 ms hook defaults; + // the poll case ends at the 1000 ms default and can only take longer + // under load, never less. + expect(fixtureSummaries(output)).toEqual([[3, 1], [3, 1]]) + expect(output.match(/Matcher did not succeed in time\./g)).toHaveLength(2) + expect(output).not.toMatch(/Test timed out in|Hook timed out in/) + const pollDurations = [...output.matchAll(/× expect\.poll budget (\d+)ms/g)].map(([, ms]) => Number(ms)) + expect(pollDurations).toHaveLength(2) + for (const ms of pollDurations) expect(ms).toBeGreaterThanOrEqual(1000) + expect(status).toBe(1) }) it('refuses a malformed budget at config load before any test runs', { timeout: 90_000 }, () => { const { status, output } = runFixture('90000ms') expect(output).toContain(`${COVERAGE_TEST_TIMEOUT_ENV} must be a positive integer, got "90000ms".`) - expect(output).not.toMatch(/lane-test-budget\.fixture\.ts \(/) + expect(fixtureSummaries(output)).toEqual([]) expect(status).toBe(1) }) }) diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index ae31a7cc27..7f90aa2fd6 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -519,18 +519,18 @@ describe('gate graph validation', () => { }) it('leaves the lane test budget to the inherited environment on both coverage gates', () => { - // vitest.config.ts reads DSH_COVERAGE_TEST_TIMEOUT_MS per inline project; - // a CLI flag here would reach only the per-test default and split the - // budget between two owners. + // vitest.config.ts reads DSH_COVERAGE_TEST_TIMEOUT_MS per inline project + // (coverageTestTimeoutOptions); a gate argument would add a second owner. for (const budget of ['15000', undefined]) { const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', budget, () => withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))) for (const id of ['coverage', 'coverage-exempt-heavy']) { const gate = gates.find(subject => subject.id === id) - expect(gate?.args).not.toEqual(expect.arrayContaining([ + if (gate === undefined) throw new Error(`ci-windows-complete must define the ${id} gate`) + expect(gate.args).not.toEqual(expect.arrayContaining([ expect.stringMatching(/^--(?:testTimeout|expect\.poll\.timeout|hookTimeout)=/), ])) - expect(gate?.env ?? {}).not.toHaveProperty('DSH_COVERAGE_TEST_TIMEOUT_MS') + expect(gate.env ?? {}).not.toHaveProperty('DSH_COVERAGE_TEST_TIMEOUT_MS') } } }) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 71ef2e3009..06de77530e 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -648,8 +648,8 @@ function lintGate(options: { needs?: string[] } = {}): Gate { // small share. A budget of 1 gives each gate 1 worker; lanes that need a strict // total of one (the serial reference jobs) also set DSH_GATE_CONCURRENCY=1, // which keeps the gates from overlapping at all. -// DSH_COVERAGE_TEST_TIMEOUT_MS reaches both gates through the inherited -// environment: vitest.config.ts applies it to every inline project. +// DSH_COVERAGE_TEST_TIMEOUT_MS is not a gate argument: vitest.config.ts reads +// it from the environment every gate inherits (coverageTestTimeoutOptions). function coverageWorkerArgs(): { instrumented: string[]; exempt: string[] } { const [flag] = positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers') if (flag === undefined) return { instrumented: [], exempt: [] } diff --git a/vitest.config.ts b/vitest.config.ts index 46c19df6bb..d0fd3c31f8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -145,11 +145,9 @@ if (coveragePartitionRaw !== undefined && coveragePartitionRaw !== '' && coverag const coveragePartitionMode = coveragePartitionRaw === '1' // Lanes on shared hosts raise the per-test, hook, and expect.poll defaults -// together through DSH_COVERAGE_TEST_TIMEOUT_MS. The budget lands in each -// inline project below because Vitest forwards only a fixed list of CLI -// overrides into projects: --testTimeout is on it, --hookTimeout and -// --expect.poll.timeout are not. Unset keeps Vitest's defaults; explicit -// describe, case, and fixture timeouts remain authoritative. +// together through DSH_COVERAGE_TEST_TIMEOUT_MS; it lands in each inline +// project below because CLI flags do not reach them (coverageTestTimeoutOptions +// owns the rule and its reach). const laneTestBudget = coverageTestTimeoutOptions(process.env[COVERAGE_TEST_TIMEOUT_ENV]) // These suites exercise process-global state, process APIs, or timing-sensitive process I/O From 23d3807ae113232d765fd4c8ab09b3d09be657fc Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Mon, 28 Sep 2026 19:12:16 +0800 Subject: [PATCH 06/42] docs: add upgrade guide structure, skill, and gate Introduce docs/upgrade-guide/v//guide.md for externally perceptible breaking changes. The dsh-create-upgrade-guide skill owns scope, placement, maintenance, and format; verify-upgrade-guides enforces layout, frontmatter, sections, and the 500-word ceiling. Guides are English-only and registered as the upgrade-guide document kind. --- .../skills/dsh-create-upgrade-guide/SKILL.md | 53 +++++++++ .agents/skills/dsh-doc/SKILL.md | 3 +- .../dsh-doc/references/metadata-links-i18n.md | 3 +- .../skills/dsh-doc/templates/upgrade-guide.md | 27 +++++ AGENTS.md | 2 + docs/AGENTS.md | 2 +- docs/i18n/README.i18n.yaml | 4 +- docs/i18n/README.md | 1 + docs/i18n/README.zh.md | 1 + package.json | 1 + scripts/doc-budgets.manifest.json | 2 +- scripts/doc-standard.spec.ts | 1 + scripts/run-gates.ts | 1 + scripts/translation-pairing.manifest.json | 3 +- scripts/verify-upgrade-guides.spec.ts | 112 ++++++++++++++++++ scripts/verify-upgrade-guides.ts | 78 ++++++++++++ 16 files changed, 287 insertions(+), 7 deletions(-) create mode 100644 .agents/skills/dsh-create-upgrade-guide/SKILL.md create mode 100644 .agents/skills/dsh-doc/templates/upgrade-guide.md create mode 100644 scripts/verify-upgrade-guides.spec.ts create mode 100644 scripts/verify-upgrade-guides.ts diff --git a/.agents/skills/dsh-create-upgrade-guide/SKILL.md b/.agents/skills/dsh-create-upgrade-guide/SKILL.md new file mode 100644 index 0000000000..cf3b46bfdd --- /dev/null +++ b/.agents/skills/dsh-create-upgrade-guide/SKILL.md @@ -0,0 +1,53 @@ +--- +name: dsh-create-upgrade-guide +description: Use when a deepseek-harness change breaks an externally perceptible surface (CLI, profiles, cordis.yml or settings keys, persisted user data, SDK or wire APIs, published package entry points), or when changing a surface that an unreleased upgrade guide already covers, to write or update docs/upgrade-guide/v//guide.md. +--- + +# DSH Create Upgrade Guide + +An upgrade guide tells a reader who runs release vT what to change to run the next release. Write it in the change that introduces the break, as soon as the break is recognized; do not defer it to release preparation. + +## Scope + +Write a guide only for breaks that someone outside the repository can observe after upgrading: + +- `dsh` commands, flags, and profile names; +- `cordis.yml`, patch, overlay, and settings keys or their accepted values; +- persisted user data: Session logs, SQLite stores, and user data directories; +- TypeScript and Python SDK exports, JSON-RPC, HTTP, and ACP messages; +- published package names and entry points. + +Pre-stable internal APIs whose consumers are all updated in the same change need no guide. A Session-format change still follows [type acknowledgements](../../../docs/cookbook/reviewing-persistence-type-changes.md); its guide states only the reader's actions and links that record. + +## Location + +`docs/upgrade-guide/v//guide.md` + +- `` is the `version` field of the root `package.json` when you write the guide, for example `v0.1.7-rc.2`. The guide describes the upgrade from that release to the next one. +- `` is a kebab-case name of the changed surface, for example `profile-flag-rename`. +- No other files belong in the tree: no index, no Chinese counterpart, no attachments. + +## Maintenance + +While the root version is unchanged, the current version directory describes the delta from vT to current `master`. When a later change touches a surface a guide there covers, update that guide in the same change; delete the guide when the break is reverted. After the version bump, guides in older version directories are frozen; a new break goes into the new version directory. Never write a guide spanning more than one release. + +## Format + +Start from the [upgrade-guide template](../dsh-doc/templates/upgrade-guide.md). `verify-upgrade-guides` enforces: + +- frontmatter with exactly `kind: upgrade-guide` and a one-sentence `description` naming what breaks; add a key only together with its gate check, then backfill every guide; +- one `#` title, then exactly the `## Change` and `## Migration` sections, in that order; +- at most 500 words, frontmatter included. + +`Change` states the old and new behavior of the surface and who is affected. `Migration` lists ordered steps that name exact files, keys, commands, or symbols, and states how to confirm the migration worked. + +When a guide needs more than 500 words, the extra text belongs elsewhere: link source files or symbols instead of restating code, move rationale into an [Agent Note](../../notes/README.md), and turn repeated mechanical steps into a script or `dsh` command that the guide invokes. + +Write English only, following [dsh-prose-standard](../dsh-prose-standard/SKILL.md). + +## Verify + +```sh +pnpm run verify-upgrade-guides +pnpm run verify-md-links +``` diff --git a/.agents/skills/dsh-doc/SKILL.md b/.agents/skills/dsh-doc/SKILL.md index 83eee5d526..0c97714672 100644 --- a/.agents/skills/dsh-doc/SKILL.md +++ b/.agents/skills/dsh-doc/SKILL.md @@ -57,6 +57,7 @@ The `kind` frontmatter field selects exactly one README template. Every kind in - `persistence-change` → [templates/persistence-change.md](templates/persistence-change.md): a dated record in `docs/persistence-changes/` — acknowledge a detected type transition with per-root predecessors and generated after schemas. - `persistence-release` → [templates/persistence-release.md](templates/persistence-release.md): a pinned tag in `docs/persistence-changes/releases/` — compare reconstructed historical types without claiming compatibility acknowledgement. - `persistence-format` → [templates/persistence-format.md](templates/persistence-format.md): a historical Session format checkpoint in `docs/persistence-changes/historical-formats/`, with complete schemas; the current writer uses the existing catalog. +- `upgrade-guide` → [templates/upgrade-guide.md](templates/upgrade-guide.md): an English-only `docs/upgrade-guide/v//guide.md` — migration steps for one externally perceptible break, owned by [dsh-create-upgrade-guide](../dsh-create-upgrade-guide/SKILL.md). Open the template before writing and follow its skeleton and rules; it states what the kind is, how the page is structured, and the fact checks each section owes. Add a new kind only together with a distinct template file, a documented repository position or declared owner, and a focused check that maps documents to it. @@ -67,7 +68,7 @@ These rules decide what a section may say. They apply to every authored human-fa - **Summary says what the subject does.** The opening `Summary` and the user-facing sections describe what a user or agent can DO with the subject — outcomes, benefits, when to choose it, main cost — never its role, type, or internal identity. In a package Summary, “what it is” means only its reader-visible capability, not its Cordis role, registrations, or internal components. Omit source identifiers unless the reader directly uses them in configuration, a command, or a public API. "The seam registers `ctx.x` and appends `x/event` records" is identity narration; "you can save a note per message and it survives restarts" is what it does. - **Developer sections explain, never enumerate.** Folded implementation content covers the overall design concept, architecture, and hand-waving dataflow — enough to understand how the package works — and links code for exact detail. No full API catalogs, exhaustive column lists, event-payload enumerations, or JSDoc restatement inside the folds. - **Dev Note is the only slop zone.** Partial ideas, scratches, undecided directions, measured artifacts, and working hypotheses live only in the final Dev Note, marked explicitly non-authoritative. Every other section is polished, current-state prose. -- **Current state only.** Ordinary documentation describes the current codebase. Dedicated `persistence-change`, `persistence-release`, and `persistence-format` records retain historical type evidence; release comparisons and format checkpoints describe observed history without asserting compatibility. Agent Notes and postmortems retain their own historical scope. +- **Current state only.** Ordinary documentation describes the current codebase. Dedicated `persistence-change`, `persistence-release`, and `persistence-format` records retain historical type evidence; release comparisons and format checkpoints describe observed history without asserting compatibility. An `upgrade-guide` describes the change from its version directory's release to the next one. Agent Notes and postmortems retain their own historical scope. - **Use controlled technical English.** Give each sentence an explicit actor and one main action when ambiguity can change behavior. Reuse one term per concept, prefer direct verbs, split stacked instructions and conditions, and preserve modality and exceptions. Apply the non-certified, ASD-STE100-inspired discipline in [the page-style reference](references/style.md#controlled-technical-english). Do not force a shorter sentence when precision would fall. ## Quality criteria diff --git a/.agents/skills/dsh-doc/references/metadata-links-i18n.md b/.agents/skills/dsh-doc/references/metadata-links-i18n.md index 19ee5dde40..34e4cc55bb 100644 --- a/.agents/skills/dsh-doc/references/metadata-links-i18n.md +++ b/.agents/skills/dsh-doc/references/metadata-links-i18n.md @@ -29,7 +29,7 @@ kind: "package-reference" ## The kind system -`kind` selects the document template directly; every kind maps to exactly one template that exists in this skill, and no template exists without a kind. Files named `docs/persistence-changes/YYYY-MM-DD-slug.md` use `persistence-change`; files named `docs/persistence-changes/releases/dsh-v*.md` use `persistence-release`. Files named `docs/persistence-changes/historical-formats/vN.md` use `persistence-format`. Their Chinese siblings use the same kind. Folder READMEs are indexes, not records. Derive package README kinds mechanically, in this order: +`kind` selects the document template directly; every kind maps to exactly one template that exists in this skill, and no template exists without a kind. Files named `docs/persistence-changes/YYYY-MM-DD-slug.md` use `persistence-change`; files named `docs/persistence-changes/releases/dsh-v*.md` use `persistence-release`. Files named `docs/persistence-changes/historical-formats/vN.md` use `persistence-format`. Their Chinese siblings use the same kind. Files named `docs/upgrade-guide/v//guide.md` use `upgrade-guide` and have no Chinese sibling. Folder READMEs are indexes, not records. Derive package README kinds mechanically, in this order: 1. The README is `packages/README.md` or `packages//README.md` → `package-group`. 2. The package manifest declares `dsh.bundle.patch` → `package-bundle`. @@ -45,6 +45,7 @@ kind: "package-reference" | `persistence-change` | `docs/persistence-changes/YYYY-MM-DD-slug.md` and its Chinese sibling | [persistence-change.md](../templates/persistence-change.md) | Historical acknowledgement: mechanically detected type changes, per-root predecessor references, compatibility decision, and generated after schemas. | | `persistence-release` | `docs/persistence-changes/releases/dsh-v*.md` and its Chinese sibling | [persistence-release.md](../templates/persistence-release.md) | Retrospective observation: pinned tag, adjacent before/after digests, actual version constants, and changed after schemas; no compatibility acknowledgement. | | `persistence-format` | `docs/persistence-changes/historical-formats/vN.md` and its Chinese sibling | [persistence-format.md](../templates/persistence-format.md) | Selected historical format: source evidence, complete root schemas and reachable types; the current writer uses the generated catalog. | +| `upgrade-guide` | `docs/upgrade-guide/v//guide.md`, English only | [upgrade-guide.md](../templates/upgrade-guide.md) | Upgrade path: one externally perceptible break from the directory's release to the next, with `Change` and `Migration` sections in at most 500 words. | Before assigning `package-library` or `package-bundle`, inspect the facts: read `package.json` for `dsh.bundle.patch` and `src/index.ts` for the entry shape (`apply` export or a default service export is a plugin; a plain module API is a library). `dsh plugin --profile add ` installs any npm dependency, but the profile reconcile activates a layer only for a package that declares `dsh.bundle`; never present that command as an install path for a library or a plain plugin. The documentation check derives the expected kind from these same facts, rejects another value, and rejects `name`, `audience`, `tags`, and README-local `i18n` metadata. Add a new kind only with a distinct template, an unambiguous repository position or declared owner, and a focused check that maps documents to it. diff --git a/.agents/skills/dsh-doc/templates/upgrade-guide.md b/.agents/skills/dsh-doc/templates/upgrade-guide.md new file mode 100644 index 0000000000..c5308115d3 --- /dev/null +++ b/.agents/skills/dsh-doc/templates/upgrade-guide.md @@ -0,0 +1,27 @@ +# Template: upgrade-guide + +Use this kind for `docs/upgrade-guide/v//guide.md`. The guides are English-only and have no folder index. [dsh-create-upgrade-guide](../../dsh-create-upgrade-guide/SKILL.md) owns scope, placement, maintenance, and length rules; `pnpm run verify-upgrade-guides` enforces them. + +## Frontmatter + +```yaml +--- +kind: upgrade-guide +description: "The externally perceptible surface that breaks and what replaces it." +--- +``` + +## Skeleton + +````markdown +# + +## Change + +State the old and new behavior of the surface, who observes it, and the pull request that introduced it. + +## Migration + +1. Name the exact file, key, command, or symbol to change, with a before/after snippet when it is shorter than prose. +2. State how to confirm the migration worked. +```` diff --git a/AGENTS.md b/AGENTS.md index 37cfd8d280..fc94452a0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,8 @@ Public APIs are pre-stable; update every consumer. Follow [version/status](docs/ Acknowledge [declared persistence-type changes](docs/cookbook/reviewing-persistence-type-changes.md). +Record each externally perceptible breaking change immediately in an [upgrade guide](.agents/skills/dsh-create-upgrade-guide/SKILL.md). + **Application launch.** Only `dsh` profiles launch supported Node apps; package bins, demos, and public SDK argv escapes are forbidden ([rule](docs/architecture.md#application-launch)). ## Repository layout diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 56485c7234..76de4d3632 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -55,7 +55,7 @@ When the gate goes red: 2. **Condense** content that belongs here but can be shorter. 3. **Raise** the ceiling only when the words need the space; justify the manifest diff in the PR. A too-low ceiling is a budget bug. -Ceilings are guardrails, not reduction targets. At or below target, retain at least 5% headroom; above target, freeze the ceiling until relocation or condensation brings the document under target. Lower a ceiling only when the document still has room. Targets: root `AGENTS.md` ≤ 1,950; `architecture.md` ≤ 2,400; subtree `AGENTS.md` ≤ 600, except `packages/AGENTS.md` ≤ 750 and this file ≤ 1,320; `packages/README.md` ≤ 994; plus `cordis-primer.md` 600, `defensive-patterns.md` 550, `testing.md` 1,300, `examples/AGENTS.md` 310. Review governs unbudgeted tiers. +Ceilings are guardrails, not reduction targets. At or below target, retain at least 5% headroom; above target, freeze the ceiling until relocation or condensation brings the document under target. Lower a ceiling only when the document still has room. Targets: root `AGENTS.md` ≤ 1,960; `architecture.md` ≤ 2,400; subtree `AGENTS.md` ≤ 600, except `packages/AGENTS.md` ≤ 750 and this file ≤ 1,320; `packages/README.md` ≤ 994; plus `cordis-primer.md` 600, `defensive-patterns.md` 550, `testing.md` 1,300, `examples/AGENTS.md` 310. Review governs unbudgeted tiers. ## The slop checklist diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index 4c794f49bd..ddb3521a91 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/README.i18n.yaml @@ -12,8 +12,8 @@ en: 08386e96eebf06d9 zh: c6d2b41199b0c466 /bilingual-documentation/scope-and-exclusions: - en: db70e758813d339b - zh: 3dcea283a08c47cb + en: 093bd091c9d46eda + zh: ab6cd07f94c740e4 /bilingual-documentation/division-of-labor: en: 88097c97cb72f3ba zh: 78a18000626cc818 diff --git a/docs/i18n/README.md b/docs/i18n/README.md index 4c23fac67e..ca970d6b2d 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -60,6 +60,7 @@ Generated English sources omit the language switcher that ordinary authored sour - `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction. - [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior. - [review-ownership/README.md](../../.github/review-ownership/README.md) — repository-internal approval policy maintained in English only. +- `docs/upgrade-guide/` — agent-facing upgrade guides maintained in English only. - `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them. **Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class. diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index a905a0bdae..edc6f972fc 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -62,6 +62,7 @@ - `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。 - [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。 - [review-ownership/README.md](../../.github/review-ownership/README.md):仓库内部审批策略,只以英文维护。 +- `docs/upgrade-guide/`:面向 agent 的升级指南,只以英文维护。 - `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。 **统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。 diff --git a/package.json b/package.json index 4317f7dc94..e254b86997 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,7 @@ "verify-archived-agent-notes": "tsx scripts/verify-archived-agent-notes.ts", "verify-type-equiv": "tsx scripts/verify-type-equiv.ts", "verify-skill-invocation-metadata": "tsx scripts/verify-skill-invocation-metadata.ts", + "verify-upgrade-guides": "tsx scripts/verify-upgrade-guides.ts", "verify-translation-prompt": "tsx scripts/verify-translation-prompt.ts", "verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts", "test:docs": "tsx scripts/run-gates.ts doc-quick", diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index d06682d7fd..2eba3f4c17 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1950, + "AGENTS.md": 1960, "docs/AGENTS.md": 1320, "docs/architecture.md": 2410, "docs/cordis-primer.md": 600, diff --git a/scripts/doc-standard.spec.ts b/scripts/doc-standard.spec.ts index e8ed611e60..84dd6e1515 100644 --- a/scripts/doc-standard.spec.ts +++ b/scripts/doc-standard.spec.ts @@ -45,6 +45,7 @@ const KIND_TEMPLATES: Readonly> = { 'persistence-change': '.agents/skills/dsh-doc/templates/persistence-change.md', 'persistence-release': '.agents/skills/dsh-doc/templates/persistence-release.md', 'persistence-format': '.agents/skills/dsh-doc/templates/persistence-format.md', + 'upgrade-guide': '.agents/skills/dsh-doc/templates/upgrade-guide.md', } /** diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index e1e2de9c21..ade1057e24 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -820,6 +820,7 @@ function docSyncLeafGates(options: { pnpmScript('skill-invocation-metadata', 'verify-skill-invocation-metadata', { label: 'skill invocation metadata', quick: true }), pnpmScript('translation-prompt', 'verify-translation-prompt', { label: 'translation prompt', quick: true }), pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets', quick: true }), + pnpmScript('upgrade-guides', 'verify-upgrade-guides', { label: 'upgrade guides', quick: true }), pnpmExec('doc-standard-tests', ['vitest', 'run', 'scripts/doc-standard.spec.ts'], { label: 'documentation standard tests', quick: true, diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index f9bb1549a2..fe517aa995 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -8,6 +8,7 @@ "docs/cordis-api/inherited.md", "docs/i18n/style-samples.md", "docs/i18n/terminology.md", - "docs/i18n/translation-prompt.md" + "docs/i18n/translation-prompt.md", + "docs/upgrade-guide/" ] } diff --git a/scripts/verify-upgrade-guides.spec.ts b/scripts/verify-upgrade-guides.spec.ts new file mode 100644 index 0000000000..07b1e516aa --- /dev/null +++ b/scripts/verify-upgrade-guides.spec.ts @@ -0,0 +1,112 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { collectUpgradeGuideViolations } from './verify-upgrade-guides.ts' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +const VALID = [ + '---', + 'kind: upgrade-guide', + 'description: "The `--preset` flag is removed."', + '---', + '', + '# `--profile` replaces `--preset`', + '', + '## Change', + '', + 'Old and new behavior.', + '', + '### Detail', + '', + '```md', + '# not a heading', + '```', + '', + '## Migration', + '', + '1. Rename the flag.', + '', +].join('\n') + +function fixtureRoot(files: Record): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-upgrade-guides-')) + roots.push(root) + for (const [file, source] of Object.entries(files)) { + const path = join(root, 'docs/upgrade-guide', file) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, source) + } + return root +} + +describe('upgrade guide gate', () => { + it('accepts an absent tree and conforming guides, including prerelease versions and CRLF files', () => { + expect(collectUpgradeGuideViolations(fixtureRoot({}))).toEqual([]) + expect(collectUpgradeGuideViolations(fixtureRoot({ + 'v0.1.7-rc.2/profile-flag/guide.md': VALID, + 'v1.0.0/crlf-item/guide.md': VALID.replaceAll('\n', '\r\n'), + }))).toEqual([]) + }) + + it('rejects files outside the version/item/guide.md layout', () => { + expect(collectUpgradeGuideViolations(fixtureRoot({ + 'README.md': VALID, + '0.1.7/item/guide.md': VALID, + 'v0.1/item/guide.md': VALID, + 'v0.1.7/Item_Name/guide.md': VALID, + 'v0.1.7/item/guide.zh.md': VALID, + 'v0.1.7/item/extra/guide.md': VALID, + }))).toEqual([ + 'docs/upgrade-guide/0.1.7/item/guide.md', + 'docs/upgrade-guide/README.md', + 'docs/upgrade-guide/v0.1.7/Item_Name/guide.md', + 'docs/upgrade-guide/v0.1.7/item/extra/guide.md', + 'docs/upgrade-guide/v0.1.7/item/guide.zh.md', + 'docs/upgrade-guide/v0.1/item/guide.md', + ].map(path => `${path}: only v//guide.md files belong in docs/upgrade-guide`)) + }) + + it('rejects missing, extra, or invalid frontmatter fields', () => { + const body = VALID.slice(VALID.indexOf('# ')) + expect(collectUpgradeGuideViolations(fixtureRoot({ + 'v0.1.7/none/guide.md': body, + 'v0.1.7/list/guide.md': `---\n- a\n---\n${body}`, + 'v0.1.7/fields/guide.md': `---\nkind: persistence-change\ndescription: ""\nversion: v0.1.7\n---\n${body}`, + }))).toEqual([ + 'docs/upgrade-guide/v0.1.7/fields/guide.md: frontmatter keys must be exactly description, kind; got description, kind, version', + 'docs/upgrade-guide/v0.1.7/fields/guide.md: kind must be upgrade-guide', + 'docs/upgrade-guide/v0.1.7/fields/guide.md: description must be a non-empty string', + 'docs/upgrade-guide/v0.1.7/list/guide.md: frontmatter must be a YAML object', + 'docs/upgrade-guide/v0.1.7/none/guide.md: must start with YAML frontmatter', + ]) + }) + + it('rejects a missing title and missing, renamed, extra, or reordered sections', () => { + const cases = { + 'no-title': VALID.replace('# `--profile` replaces `--preset`\n', ''), + 'two-titles': VALID.replace('## Migration', '# Migration'), + 'renamed': VALID.replace('## Change', '## Changes'), + 'extra': `${VALID}\n## References\n`, + 'reordered': VALID.replace('## Change', '## Placeholder').replace('## Migration', '## Change').replace('## Placeholder', '## Migration'), + } + const violations = collectUpgradeGuideViolations(fixtureRoot(Object.fromEntries( + Object.entries(cases).map(([item, source]) => [`v0.1.7/${item}/guide.md`, source]), + ))) + expect(violations.map(violation => violation.split('/')[3])).toEqual(['extra', 'no-title', 'renamed', 'reordered', 'two-titles']) + expect(violations[0]).toBe('docs/upgrade-guide/v0.1.7/extra/guide.md: headings must be one "#" title followed by "## Change", "## Migration"; got "# `--profile` replaces `--preset`", "## Change", "## Migration", "## References"') + }) + + it('rejects a guide over 500 words, counting frontmatter', () => { + const words = VALID.split(/\s+/u).filter(Boolean).length + expect(collectUpgradeGuideViolations(fixtureRoot({ + 'v0.1.7/at-limit/guide.md': `${VALID}${'word '.repeat(500 - words)}\n`, + 'v0.1.7/over-limit/guide.md': `${VALID}${'word '.repeat(501 - words)}\n`, + }))).toEqual(['docs/upgrade-guide/v0.1.7/over-limit/guide.md: 501 words exceeds the 500-word ceiling']) + }) +}) diff --git a/scripts/verify-upgrade-guides.ts b/scripts/verify-upgrade-guides.ts new file mode 100644 index 0000000000..9a776013af --- /dev/null +++ b/scripts/verify-upgrade-guides.ts @@ -0,0 +1,78 @@ +/** + * Enforce the `docs/upgrade-guide/v//guide.md` layout, metadata, + * sections, and word ceiling defined by the `dsh-create-upgrade-guide` skill. + * @module scripts/verify-upgrade-guides + */ + +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { JSON_SCHEMA, load } from 'js-yaml' +import { fromMarkdown } from 'mdast-util-from-markdown' + +const ROOT = resolve(import.meta.dirname, '..') +const GUIDE_ROOT = 'docs/upgrade-guide' +const MAX_WORDS = 500 +const FRONTMATTER_KEYS = ['description', 'kind'] +const SECTIONS = ['Change', 'Migration'] +const SEMVER = String.raw`(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?` +const GUIDE_PATH = new RegExp(`^v${SEMVER}/[a-z0-9]+(?:-[a-z0-9]+)*/guide\\.md$`, 'u') + +/** Return one guide's frontmatter and structure violations. */ +function guideViolations(source: string): string[] { + const violations: string[] = [] + const words = source.split(/\s+/u).filter(Boolean).length + if (words > MAX_WORDS) violations.push(`${String(words)} words exceeds the ${String(MAX_WORDS)}-word ceiling`) + + const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/u.exec(source) + if (frontmatter === null) return [...violations, 'must start with YAML frontmatter'] + const metadata = load(frontmatter[1] as string, { schema: JSON_SCHEMA }) + if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) return [...violations, 'frontmatter must be a YAML object'] + const fields = metadata as Record + const keys = Object.keys(fields).sort() + if (keys.join() !== FRONTMATTER_KEYS.join()) violations.push(`frontmatter keys must be exactly ${FRONTMATTER_KEYS.join(', ')}; got ${keys.join(', ') || 'none'}`) + if (fields.kind !== 'upgrade-guide') violations.push('kind must be upgrade-guide') + if (typeof fields.description !== 'string' || fields.description.trim() === '') violations.push('description must be a non-empty string') + + const body = source.slice(frontmatter[0].length) + const outline = fromMarkdown(body).children + .flatMap(node => node.type === 'heading' && node.depth <= 2 ? [body.slice(node.position?.start.offset, node.position?.end.offset)] : []) + const expected = SECTIONS.map(section => `## ${section}`) + if (outline.length !== expected.length + 1 || !outline[0]?.startsWith('# ') || outline.slice(1).join('\n') !== expected.join('\n')) { + violations.push(`headings must be one "#" title followed by ${expected.map(heading => `"${heading}"`).join(', ')}; got ${outline.map(heading => `"${heading}"`).join(', ') || 'none'}`) + } + return violations +} + +/** + * Report upgrade-guide tree violations. + * @param root - Repository root containing `docs/upgrade-guide`. + * @returns diagnostics prefixed by the repository-relative file path; empty when the tree is absent. + */ +export function collectUpgradeGuideViolations(root: string): string[] { + const directory = join(root, GUIDE_ROOT) + if (!existsSync(directory)) return [] + const violations: string[] = [] + const files = readdirSync(directory, { recursive: true, withFileTypes: true }) + .filter(entry => !entry.isDirectory()) + .map(entry => join(entry.parentPath, entry.name).slice(directory.length + 1).replaceAll('\\', '/')) + .sort() + for (const file of files) { + const path = `${GUIDE_ROOT}/${file}` + if (!GUIDE_PATH.test(file)) { + violations.push(`${path}: only v//guide.md files belong in ${GUIDE_ROOT}`) + continue + } + for (const violation of guideViolations(readFileSync(join(directory, file), 'utf8'))) violations.push(`${path}: ${violation}`) + } + return violations +} + +if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) { + const violations = collectUpgradeGuideViolations(ROOT) + if (violations.length > 0) { + process.stderr.write('verify-upgrade-guides: violations found (see .agents/skills/dsh-create-upgrade-guide/SKILL.md):\n') + for (const violation of violations) process.stderr.write(` ${violation}\n`) + process.exit(1) + } + process.stdout.write('verify-upgrade-guides: upgrade guides conform.\n') +} From aaceea05541445d53cbff8bfde513835ec037768 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 28 Sep 2026 19:58:55 +0800 Subject: [PATCH 07/42] feat(desktop): add fixed latest installer upload commands --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 + apps/desktop/README.zh.md | 2 + apps/desktop/package.json | 5 +- apps/desktop/scripts/desktop-upload-plan.ts | 12 ++-- apps/desktop/scripts/upload-target.ts | 5 +- .../desktop/tests/desktop-upload-plan.spec.ts | 56 ++++++++++++++++- .../expected/latest-installer-uploads.json | 62 +++++++++++++++++++ package.json | 3 + 9 files changed, 139 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/tests/expected/latest-installer-uploads.json diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index d417b4dfbb..8299ee0db7 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -39,8 +39,8 @@ en: f0e345fd89868803 zh: fd2c775ad23fa2f0 /deepseek-harness-desktop/package/upload-updates: - en: c64d7fe2fe2534b7 - zh: 2188e1ae99ab4b33 + en: 4f6a90a78a4771ee + zh: 9b9e735dfd3a4c2d /deepseek-harness-desktop/package/unsigned-windows-test-installer: en: cfd5780dd26a8e46 zh: f7a16b1ede793b7b diff --git a/apps/desktop/README.md b/apps/desktop/README.md index bb2295986b..c6b2d63b10 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -233,6 +233,8 @@ When extraction fails, the installer writes a report with the 7-Zip result and i ### Upload updates +Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Deployment infrastructure must configure revalidation or purge cached fixed downloads after replacement. Local loopback tests cover the upload; release operators own real COS and CDN acceptance. + Test and production uploads through `upload:*` retain a fresh `.desktop-build/upload-records/--*` directory after release preflight. `plan.json` records destination, version, every file's size/SHA-512, and published YAML bytes; flushed `events.jsonl` records PUT intent and available response status/request ID; `result.json` records completion or the last failed stage. A missing final result means interruption or unavailable storage, not success. No credential values, authorization headers, or raw SDK errors are recorded. Audit-write failures stop later PUTs. Every object is one streamed Tencent COS PUT with an explicit length and Content-MD5; the COS SDK repeats a request only when its body is not a stream, and this uploader does not retry either. Retain partial records and inspect remote state before another operation: a timeout or failed receipt write does not prove that the object was not stored. These records are local, not tamper-proof or automatically backed up; archive each release's records with its build evidence in controlled storage. Public CDN readback remains separate release qualification, explicitly marked `not-performed` in the upload result. Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target -Bucket ` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 40683dfd85..49fffe9193 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -235,6 +235,8 @@ Windows 安装器在启动时和选定目标目录后检查应用是否正在运 ### 上传更新 +固定安装包下载在仓库根目录使用 `pnpm run upload:latest:mac:arm64`、`pnpm run upload:latest:mac:x64` 或 `pnpm run upload:latest:win:x64`。它们把已完成目标的 DMG 或 EXE 上传到 `desktop/dsh-latest-macos-arm64.dmg`、`desktop/dsh-latest-macos-x64.dmg` 或 `desktop/dsh-latest-windows-x64.exe`。生产 URL 以 `https://download.deepseek.com/` 开头;测试 URL 使用 `DOWNLOAD_TEST_ORIGIN`。两者均复用目标 dotenv 中的部署、bucket 和凭据、全部常规发布校验以及下述上传审计。每个命令覆盖一个固定对象,不发布更新清单、blockmap 或发布标签。测试固定下载在测试 bucket 中跨发布 ID 共享;包内更新 URL 仍要求原有发布 ID。发布由操作人员显式执行,也允许预发布版本。部署基础设施必须配置重新验证,或在覆盖后清除固定下载的缓存。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。 + test 与 production 的 `upload:*` 上传在发布前置检查通过后,分别保留新的 `.desktop-build/upload-records/--*` 目录。`plan.json` 记录目标、版本、每个文件的大小/SHA-512 和发布的 YAML 字节;刷盘的 `events.jsonl` 记录 PUT 意图及可用的响应状态/请求 ID;`result.json` 记录完成结果或最后失败阶段。缺少最终结果表示中断或存储不可用,不表示成功。不记录凭据值、认证头或原始 SDK 错误。审计写入失败即停止后续 PUT。每个对象都以一次流式腾讯 COS PUT 上传,并携带显式长度与 Content-MD5;COS SDK 仅在请求体不是流时才会重发请求,上传器自身也不重试。保留部分记录,检查远端状态后再执行下一次操作:超时或回执写入失败不能证明对象未存储。这些记录仅在本地,不防篡改,也不会自动备份;每次发布应将它们与构建证据一同归档到受控存储。公网 CDN 回读仍是单独的发布验收,上传结果明确标记为 `not-performed`。 Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target -Bucket ` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c46368a2ac..8b7e44ca00 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -35,7 +35,10 @@ "package:win:x64:dir": "tsx scripts/package-target.ts win-x64 --dir", "upload:mac:arm64": "tsx scripts/upload-target.ts mac-arm64", "upload:mac:x64": "tsx scripts/upload-target.ts mac-x64", - "upload:win:x64": "tsx scripts/upload-target.ts win-x64" + "upload:win:x64": "tsx scripts/upload-target.ts win-x64", + "upload:latest:mac:arm64": "tsx scripts/upload-target.ts mac-arm64 --latest", + "upload:latest:mac:x64": "tsx scripts/upload-target.ts mac-x64 --latest", + "upload:latest:win:x64": "tsx scripts/upload-target.ts win-x64 --latest" }, "dependencies": { "electron-updater": "^6.8.9", diff --git a/apps/desktop/scripts/desktop-upload-plan.ts b/apps/desktop/scripts/desktop-upload-plan.ts index e14228cf69..5bf1e84ac5 100644 --- a/apps/desktop/scripts/desktop-upload-plan.ts +++ b/apps/desktop/scripts/desktop-upload-plan.ts @@ -38,7 +38,7 @@ export interface DesktopUploadArtifact { readonly contents?: string } -/** A fully validated upload operation with channel metadata ordered last. */ +/** A validated installer or update upload, with any channel metadata ordered last. */ export interface DesktopUploadPlan { readonly environment: 'test' | 'production' readonly target: DesktopPackageTargetName @@ -56,6 +56,8 @@ export interface DesktopUploadPlan { /** Filesystem and environment inputs used to validate one upload. */ export interface DesktopUploadPlanOptions { + /** Publish only the installer at its fixed download URL, replacing the previous object. */ + readonly latest?: boolean readonly environment?: NodeJS.ProcessEnv readonly repositoryRoot?: string readonly appRoot?: string @@ -171,7 +173,7 @@ function uploadArtifact( * Validate the completed package record, dsh version, update metadata, hashes, and target files. * @param targetName - Fixed platform and architecture selected by the upload command. * @param options - Optional filesystem roots and environment for tests or release automation. - * @returns An upload plan whose mutable channel metadata is the final entry. + * @returns A fixed installer upload or an update plan with channel metadata ordered last. */ export async function createDesktopUploadPlan( targetName: DesktopPackageTargetName, @@ -272,15 +274,17 @@ export async function createDesktopUploadPlan( const stableFilename = metadataFilename.replace('nightly', 'latest') artifacts.push({ ...channelArtifact, filename: stableFilename, key: `${update.keyPrefix}/${stableFilename}` }) } + const latestFilename = `dsh-latest-${target.platform === 'darwin' ? 'macos' : 'windows'}-${target.arch}.${target.platform === 'darwin' ? 'dmg' : 'exe'}` + const latestKey = `desktop/${latestFilename}` return { environment: update.environment, target: targetName, version: buildVersion, - publicUrl: update.publicUrl, + publicUrl: options.latest ? `${update.origin}/${latestKey}` : update.publicUrl, bucket: update.bucket, secretIdEnvName: update.secretIdEnvName, secretKeyEnvName: update.secretKeyEnvName, - artifacts, + artifacts: options.latest ? [{ ...artifacts[0]!, filename: latestFilename, key: latestKey }] : artifacts, ...typeof buildRecord.commit === 'string' ? { commit: buildRecord.commit } : {}, ...typeof buildRecord.dirty === 'boolean' ? { dirty: buildRecord.dirty } : {}, } diff --git a/apps/desktop/scripts/upload-target.ts b/apps/desktop/scripts/upload-target.ts index 055a398419..8a6d625b63 100644 --- a/apps/desktop/scripts/upload-target.ts +++ b/apps/desktop/scripts/upload-target.ts @@ -60,6 +60,7 @@ export function resolveCredentialUploadEnvironment( async function main(): Promise { const { positionals, values } = parseArgs({ args: process.argv.slice(2), allowPositionals: true, options: { + latest: { type: 'boolean' }, 'credential-launcher': { type: 'boolean' }, environment: { type: 'string' }, bucket: { type: 'string' }, } }) const target = positionals[0] @@ -79,14 +80,14 @@ async function main(): Promise { const environment = launcher ? resolveCredentialUploadEnvironment(fileEnvironment, process.env, values.environment as 'test' | 'production', values.bucket!, name) : fileEnvironment - const plan = await createDesktopUploadPlan(name, { environment }) + const plan = await createDesktopUploadPlan(name, { environment, latest: values.latest === true }) const cos = createDesktopCos({ secretId: requiredEnvironmentValue(environment, plan.secretIdEnvName), secretKey: requiredEnvironmentValue(environment, plan.secretKeyEnvName), }) process.stdout.write(`desktop upload: ${plan.target} ${plan.version} -> ${plan.publicUrl}\n`) await uploadDesktopRelease(plan, cos, resolve(import.meta.dirname, '../.desktop-build/upload-records')) - if (plan.environment === 'production') recordProductionRelease(plan) + if (plan.environment === 'production' && !values.latest) recordProductionRelease(plan) } /** diff --git a/apps/desktop/tests/desktop-upload-plan.spec.ts b/apps/desktop/tests/desktop-upload-plan.spec.ts index bf4d68843c..f31dfc1497 100644 --- a/apps/desktop/tests/desktop-upload-plan.spec.ts +++ b/apps/desktop/tests/desktop-upload-plan.spec.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto' -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { createRequire } from 'node:module' import { join } from 'node:path' @@ -8,12 +8,15 @@ import { load } from 'js-yaml' import { createDesktopUploadPlan } from '../scripts/desktop-upload-plan.ts' import { desktopUpdateMetadataFilename } from '../scripts/desktop-auto-update-environment.mjs' import type { DesktopPackageTargetName } from '../scripts/package-target.ts' +import { createDesktopCos } from '../scripts/desktop-cos.ts' +import { uploadDesktopRelease } from '../scripts/desktop-upload-run.ts' +import { startCosLoopback } from './cos-loopback.ts' const temporaryDirectories: string[] = [] const TEST_ORIGIN = 'https://desktop-updates.example.com' -const TEST_BUCKET = 'test-download-bucket' +const TEST_BUCKET = 'test-download-bucket-1250000000' const RELEASE_ID = '0123456789abcdef0123456789abcdef' -const PRODUCTION_BUCKET = 'production-download-bucket' +const PRODUCTION_BUCKET = 'production-download-bucket-1250000000' const require = createRequire(import.meta.url) const { createBlockmap } = require('app-builder-lib/out/targets/differentialUpdateInfoBuilder.js') as { createBlockmap: (file: string, target: object, packager: { info: { emitArtifactBuildCompleted(event: object): Promise } }, @@ -110,6 +113,53 @@ afterEach(async () => { }) describe('desktop upload plan', () => { + it('uploads only the selected latest installer to each deployment using the existing COS transport', async () => { + const published = [] + for (const environment of ['production', 'test'] as const) { + for (const target of ['mac-arm64', 'mac-x64', 'win-x64'] as const) { + const paths = await fixture(target, '1.2.3', environment) + const plan = await createDesktopUploadPlan(target, { ...paths, latest: true }) + const loopback = await startCosLoopback() + try { + const cos = createDesktopCos({ secretId: 'fixture-id', secretKey: 'fixture-secret' }) + loopback.redirect(cos) + const directory = await uploadDesktopRelease(plan, cos, join(paths.appRoot, 'records')) + expect(plan.artifacts).toHaveLength(1) + const artifact = plan.artifacts[0]! + expect(loopback.requests).toHaveLength(1) + const request = loopback.requests[0]! + expect(request.method).toBe('PUT') + expect(request.path).toBe(`/${artifact.key}`) + expect(request.body).toEqual(await readFile(artifact.path)) + expect(JSON.parse(await readFile(join(directory, 'result.json'), 'utf8'))).toMatchObject({ + success: true, confirmedPuts: 1, publicReadback: 'not-performed', + }) + published.push({ environment, target, bucket: plan.bucket, publicUrl: plan.publicUrl, + filename: artifact.filename, key: artifact.key, contentType: artifact.contentType, + channelMetadata: artifact.channelMetadata }) + } finally { + await loopback.close() + } + } + } + await expect(`${JSON.stringify(published, null, 2)}\n`).toMatchFileSnapshot('./expected/latest-installer-uploads.json') + }) + + it.each(['completion', 'deployment', 'checksum'] as const)('rejects invalid %s before planning a latest upload', async (failure) => { + const paths = await fixture('win-x64', '1.2.3', 'production') + if (failure === 'completion') await rm(join(paths.artifactsRoot, 'win-x64-release.json')) + if (failure === 'deployment') Object.assign(paths.environment, { + DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: TEST_ORIGIN, + DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID, DOWNLOAD_TEST_COS_BUCKET: TEST_BUCKET, + }) + if (failure === 'checksum') { + const path = join(paths.artifactsRoot, 'deepseek-harness-1.2.3-win-x64.exe') + await writeFile(path, Buffer.alloc((await readFile(path)).length)) + } + await expect(createDesktopUploadPlan('win-x64', { ...paths, latest: true })) + .rejects.toThrow(failure === 'checksum' ? /SHA-512/u : /completion record/u) + }) + it('publishes fixed feeds referencing versioned binaries without overriding CDN cache policy', async () => { const paths = await fixture('win-x64', '1.2.3', 'production') const plan = await createDesktopUploadPlan('win-x64', paths) diff --git a/apps/desktop/tests/expected/latest-installer-uploads.json b/apps/desktop/tests/expected/latest-installer-uploads.json new file mode 100644 index 0000000000..b4b6d90b4f --- /dev/null +++ b/apps/desktop/tests/expected/latest-installer-uploads.json @@ -0,0 +1,62 @@ +[ + { + "environment": "production", + "target": "mac-arm64", + "bucket": "production-download-bucket-1250000000", + "publicUrl": "https://download.deepseek.com/desktop/dsh-latest-macos-arm64.dmg", + "filename": "dsh-latest-macos-arm64.dmg", + "key": "desktop/dsh-latest-macos-arm64.dmg", + "contentType": "application/x-apple-diskimage", + "channelMetadata": false + }, + { + "environment": "production", + "target": "mac-x64", + "bucket": "production-download-bucket-1250000000", + "publicUrl": "https://download.deepseek.com/desktop/dsh-latest-macos-x64.dmg", + "filename": "dsh-latest-macos-x64.dmg", + "key": "desktop/dsh-latest-macos-x64.dmg", + "contentType": "application/x-apple-diskimage", + "channelMetadata": false + }, + { + "environment": "production", + "target": "win-x64", + "bucket": "production-download-bucket-1250000000", + "publicUrl": "https://download.deepseek.com/desktop/dsh-latest-windows-x64.exe", + "filename": "dsh-latest-windows-x64.exe", + "key": "desktop/dsh-latest-windows-x64.exe", + "contentType": "application/vnd.microsoft.portable-executable", + "channelMetadata": false + }, + { + "environment": "test", + "target": "mac-arm64", + "bucket": "test-download-bucket-1250000000", + "publicUrl": "https://desktop-updates.example.com/desktop/dsh-latest-macos-arm64.dmg", + "filename": "dsh-latest-macos-arm64.dmg", + "key": "desktop/dsh-latest-macos-arm64.dmg", + "contentType": "application/x-apple-diskimage", + "channelMetadata": false + }, + { + "environment": "test", + "target": "mac-x64", + "bucket": "test-download-bucket-1250000000", + "publicUrl": "https://desktop-updates.example.com/desktop/dsh-latest-macos-x64.dmg", + "filename": "dsh-latest-macos-x64.dmg", + "key": "desktop/dsh-latest-macos-x64.dmg", + "contentType": "application/x-apple-diskimage", + "channelMetadata": false + }, + { + "environment": "test", + "target": "win-x64", + "bucket": "test-download-bucket-1250000000", + "publicUrl": "https://desktop-updates.example.com/desktop/dsh-latest-windows-x64.exe", + "filename": "dsh-latest-windows-x64.exe", + "key": "desktop/dsh-latest-windows-x64.exe", + "contentType": "application/vnd.microsoft.portable-executable", + "channelMetadata": false + } +] diff --git a/package.json b/package.json index 4317f7dc94..056662a22a 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,9 @@ "upload:mac:arm64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:mac:arm64", "upload:mac:x64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:mac:x64", "upload:win:x64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:win:x64", + "upload:latest:mac:arm64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:latest:mac:arm64", + "upload:latest:mac:x64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:latest:mac:x64", + "upload:latest:win:x64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:latest:win:x64", "clean": "tsx scripts/clean.ts", "change-scope": "tsx scripts/change-scope.ts", "typecheck": "npm run build:lib:host && npm run typecheck:contracts-ready", From dbf31101d89538f4c5520bab79009924d2f1e515 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 28 Sep 2026 22:43:06 +0800 Subject: [PATCH 08/42] fix(desktop): grant Intel Node executable-memory signing entitlement --- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/scripts/macos-runtime.ts | 7 +++++-- .../desktop/scripts/node-x64-entitlements.plist | 3 +++ apps/desktop/scripts/prepare-dsh.ts | 6 +++--- apps/desktop/tests/macos-cache-policy.spec.ts | 2 ++ apps/desktop/tests/macos-runtime.spec.ts | 17 +++++++++++------ 8 files changed, 28 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/scripts/node-x64-entitlements.plist diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 20819d01f5..1d1e864d80 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -12,8 +12,8 @@ en: 069fdfd72d2ea26a zh: 76ae549d43c99bb4 /deepseek-harness-desktop/key-technical-decisions/bundled-workspace-dependencies: - en: 31cbb7452be8e1ad - zh: 502ec556e2ec301e + en: 320733f93ede61a4 + zh: 33206c3c4753c802 /deepseek-harness-desktop/installation-ownership: en: 863ebc136313b73b zh: 6aa9a9925ceaa80a diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 7d61a39a0d..352dddc01c 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -60,7 +60,7 @@ The payload follows the Desktop release. `runtime.json` records the Desktop vers The private Desktop `runtime/bin` directory is added only to package-installation processes, not the Host PATH inherited by PTC and agent shells. This tool does not change PATH, environment variables or user package-manager configuration. pnpm retains its own defaults and user settings for global packages, executable entries and its store, including native errors when the environment does not support global installation. There is no separate dependency updater. [The primary-runtime decision](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md) records these choices. -Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](../../scripts/primary-runtime/lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; the shared builder resolves pnpm from the root development dependency pin. The root package-manager version and the Desktop pin are checked for agreement. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. Native-target checks verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets//runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit. +Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](../../scripts/primary-runtime/lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; the shared builder resolves pnpm from the root development dependency pin. The root package-manager version and the Desktop pin are checked for agreement. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. Native-target checks verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8; the x64 target also receives `com.apple.security.cs.allow-unsigned-executable-memory` for executable code pages, including under Rosetta. ARM64 Node and Office helpers retain JIT-only permissions. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets//runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit. | Decision | Why | Direct consequence | |---|---|---| diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 12a7d53fea..c5bf14cd9a 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -62,7 +62,7 @@ Desktop 默认注册 `office-docx`、`office-pptx` 和 `office-xlsx`。这些技 Desktop 私有的 `runtime/bin` 目录仅添加到包安装进程,不进入 PTC 和 agent shell 从 Host 继承的 PATH。该工具不修改 PATH、环境变量或用户包管理器配置。pnpm 的全局包、命令入口和 store 保留自身默认值及用户设置,包括环境不支持全局安装时的原生错误。不提供独立依赖更新器。[第一方 Runtime 决策](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md)记录这些选择。 -Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](../../scripts/primary-runtime/lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;共享构建器从根开发依赖中解析 pnpm 固定版本。测试检查根 package-manager 版本和 Desktop 固定版本保持一致。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。本机目标检查在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets//runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。 +Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](../../scripts/primary-runtime/lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;共享构建器从根开发依赖中解析 pnpm 固定版本。测试检查根 package-manager 版本和 Desktop 固定版本保持一致。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。本机目标检查在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限;x64 目标还获得 `com.apple.security.cs.allow-unsigned-executable-memory`,允许可执行代码页,包括在 Rosetta 下运行时。ARM64 Node 和 Office 辅助程序保留仅 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets//runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。 | 决策 | 原因 | 直接结果 | |---|---|---| diff --git a/apps/desktop/scripts/macos-runtime.ts b/apps/desktop/scripts/macos-runtime.ts index b5d35f1672..bcfcfd9932 100644 --- a/apps/desktop/scripts/macos-runtime.ts +++ b/apps/desktop/scripts/macos-runtime.ts @@ -24,11 +24,12 @@ function magic(path: string): string { * @param root - Self-contained production runtime without symlinks. * @param appId - Release application identifier. * @param expected - Required signing identity. + * @param arch - Target runtime architecture, independent of the signing host. * @param cacheDirectory Optional content-addressed cache; requires the keychain-owned signing probe. * @returns Number of signed native files. */ export async function signMacOSRuntime( - root: string, appId: string, expected: MacOSSigningEnvironment, cacheDirectory?: string, + root: string, appId: string, expected: MacOSSigningEnvironment, arch: 'arm64' | 'x64', cacheDirectory?: string, ): Promise { const files = inventoryDesktopRuntime(root).map(file => file.path).filter(path => MACH_O_MAGICS.has(magic(join(root, path)))) const policy = cacheDirectory === undefined ? undefined : macOSCachePolicy(process.env.DSH_DESKTOP_MACOS_SIGNING_PROBE ?? '') @@ -42,7 +43,9 @@ export async function signMacOSRuntime( const identifier = `${appId}.runtime.${createHash('sha256').update(path).digest('hex')}` const needsJit = path === 'dependencies/node/bin/node' || /^node_modules\/@deepseek-ai\/libreoffice-kit-darwin-(?:arm64|x64)\/bin\/libreoffice-kit$/u.test(path) - const entitlements = needsJit ? join(import.meta.dirname, 'jit-entitlements.plist') : undefined + const jitPlist = path === 'dependencies/node/bin/node' && arch === 'x64' + ? 'node-x64-entitlements.plist' : 'jit-entitlements.plist' + const entitlements = needsJit ? join(import.meta.dirname, jitPlist) : undefined const file = join(root, path) const thin = ['cefaedfe', 'cffaedfe', 'feedface', 'feedfacf'].includes(magic(file)) if (cacheDirectory !== undefined && policy !== undefined && thin) { diff --git a/apps/desktop/scripts/node-x64-entitlements.plist b/apps/desktop/scripts/node-x64-entitlements.plist new file mode 100644 index 0000000000..74193b83fa --- /dev/null +++ b/apps/desktop/scripts/node-x64-entitlements.plist @@ -0,0 +1,3 @@ + + +com.apple.security.cs.allow-jitcom.apple.security.cs.allow-unsigned-executable-memory diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts index 377c300efb..a70c03509e 100644 --- a/apps/desktop/scripts/prepare-dsh.ts +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -130,7 +130,7 @@ async function main(): Promise { await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:install', () => runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile'])) const packageSet = readDesktopCorePackageSet(BUILD_ROOT, release.version) const targetName = resolveDesktopBuildTarget() - const target = { platform: process.platform, arch: targetName.endsWith('arm64') ? 'arm64' : 'x64' } + const target = { platform: process.platform, arch: targetName.endsWith('arm64') ? 'arm64' as const : 'x64' as const } const modules = join(BUILD_ROOT, 'node_modules') const officeManifest = JSON.parse(readFileSync(join(modules, '@deepseek-ai/libreoffice-kit/package.json'), 'utf8')) const officeEngine = selectOfficeEngine(officeManifest, target) @@ -154,8 +154,8 @@ async function main(): Promise { throw new Error(`desktop runtime: missing required LibreOffice engine ${officeEngine}`) } if (process.platform === 'darwin') { - await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:dsh-native', () => signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), join(BUILD_PATHS.root, 'signature-cache'))) - await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:primary-native', () => signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), join(BUILD_PATHS.root, 'signature-cache'))) + await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:dsh-native', () => signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), target.arch, join(BUILD_PATHS.root, 'signature-cache'))) + await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:primary-native', () => signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), target.arch, join(BUILD_PATHS.root, 'signature-cache'))) } await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:manifests', () => prepareRuntimeManifests(DSH_OUTPUT_ROOT)) await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:primary-smoke', async () => smokePrimaryRuntime(join(RUNTIME_ROOT, 'primary-runtime'))) diff --git a/apps/desktop/tests/macos-cache-policy.spec.ts b/apps/desktop/tests/macos-cache-policy.spec.ts index cca57085e2..2c56986c8f 100644 --- a/apps/desktop/tests/macos-cache-policy.spec.ts +++ b/apps/desktop/tests/macos-cache-policy.spec.ts @@ -52,6 +52,8 @@ it('includes certificate, identifier and entitlement contents in the cache polic const original = create('com.example.file', identity).policy expect(create('com.other.file', identity).policy).not.toBe(original) expect(create('com.example.file', identity, join(import.meta.dirname, '../scripts/jit-entitlements.plist')).policy).not.toBe(original) + const jit = create('com.example.file', identity, join(import.meta.dirname, '../scripts/jit-entitlements.plist')).policy + expect(create('com.example.file', identity, join(import.meta.dirname, '../scripts/node-x64-entitlements.plist')).policy).not.toBe(jit) certificate = 'renewed-certificate' expect(macOSCachePolicy('probe')('com.example.file', identity).policy).not.toBe(original) }) diff --git a/apps/desktop/tests/macos-runtime.spec.ts b/apps/desktop/tests/macos-runtime.spec.ts index 5ca9908c28..ab248522ae 100644 --- a/apps/desktop/tests/macos-runtime.spec.ts +++ b/apps/desktop/tests/macos-runtime.spec.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' @@ -21,7 +21,7 @@ it('signs Mach-O files in their final locations and verifies each signature', as const path = root() writeFileSync(join(path, 'addon.node'), Buffer.from('cffaedfe00000000', 'hex')) writeFileSync(join(path, 'source.js'), 'export {}') - await expect(signMacOSRuntime(path, 'com.example.app', identity)).resolves.toBe(1) + await expect(signMacOSRuntime(path, 'com.example.app', identity, 'arm64')).resolves.toBe(1) expect(signMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'addon.node'), expect.stringMatching(/^com\.example\.app\.runtime\.[a-f0-9]{64}$/u), identity, undefined) expect(verifyMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'addon.node'), identity) }) @@ -38,7 +38,7 @@ it('awaits other signers before rejecting and permitting output cleanup', async await barrier }) let completed = false - const result = signMacOSRuntime(path, 'com.example.app', identity).catch((error: unknown) => { completed = true; return error }) + const result = signMacOSRuntime(path, 'com.example.app', identity, 'arm64').catch((error: unknown) => { completed = true; return error }) try { await ready expect(completed).toBe(false) @@ -47,7 +47,7 @@ it('awaits other signers before rejecting and permitting output cleanup', async expect(verifyMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'b.node'), identity) }) -it('grants JIT only to the standalone Node interpreter and native Office helpers', async () => { +it.each(['arm64', 'x64'] as const)('limits %s executable-memory permissions to the standalone Node interpreter', async (arch) => { const path = root() mkdirSync(join(path, 'dependencies/node/bin'), { recursive: true }) const node = join(path, 'dependencies/node/bin/node') @@ -55,8 +55,13 @@ it('grants JIT only to the standalone Node interpreter and native Office helpers const helpers = ['arm64', 'x64'].map(arch => join(path, 'node_modules/@deepseek-ai', `libreoffice-kit-darwin-${arch}`, 'bin/libreoffice-kit')) for (const helper of helpers) mkdirSync(join(helper, '..'), { recursive: true }) for (const file of [node, addon, ...helpers]) writeFileSync(file, Buffer.from('cffaedfe00000000', 'hex')) - await signMacOSRuntime(path, 'com.example.app', identity) - for (const file of [node, ...helpers]) { + await signMacOSRuntime(path, 'com.example.app', identity, arch) + const nodePlist = join(import.meta.dirname, '../scripts', arch === 'x64' ? 'node-x64-entitlements.plist' : 'jit-entitlements.plist') + expect(signMacOSRuntimeCode).toHaveBeenCalledWith(node, expect.any(String), identity, nodePlist) + const xml = readFileSync(nodePlist, 'utf8') + expect(xml).toContain('com.apple.security.cs.allow-jit') + expect(xml.includes('com.apple.security.cs.allow-unsigned-executable-memory')).toBe(arch === 'x64') + for (const file of helpers) { expect(signMacOSRuntimeCode).toHaveBeenCalledWith(file, expect.any(String), identity, join(import.meta.dirname, '../scripts/jit-entitlements.plist')) } From 6773e10645bef3896eba5e911314237910204cc0 Mon Sep 17 00:00:00 2001 From: Yao Meng Date: Mon, 21 Sep 2026 14:04:44 +0800 Subject: [PATCH 09/42] feat(web): add fuzzy search to composer model picker Signed-off-by: Yao Meng --- apps/web/tests/default-model.e2e.ts | 14 ++++- .../expected/default-model/search.expected.md | 5 ++ .../ui-model-selection/README.i18n.yaml | 8 +-- packages/client/ui-model-selection/README.md | 4 +- .../client/ui-model-selection/README.zh.md | 4 +- .../src/client/ModelSelect.module.css | 5 ++ .../src/client/ModelSelect.tsx | 47 ++++++++++++----- .../ui-model-selection/src/client/locales.ts | 4 ++ .../tests/model-select.client.spec.tsx | 52 ++++++++++++++++--- 9 files changed, 114 insertions(+), 29 deletions(-) create mode 100644 apps/web/tests/expected/default-model/search.expected.md diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index aa17206061..88610f68ec 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -107,6 +107,18 @@ describe('web e2e: the composer model switch is the default for later sessions', expect(await trigger.evaluate(element => getComputedStyle(element).fontWeight)).toBe('400') await trigger.click() await page.getByRole('menuitem', { name: /模型/ }).click() + const search = page.getByRole('searchbox', { name: '搜索模型…' }) + await search.fill('zzzz') + await page.getByText('没有匹配的模型。', { exact: true }).waitFor() + await search.fill(' ACMLG ') + expect(await page.getByRole('menuitemradio').allTextContents()).toEqual(['Acme Large']) + expect(await page.getByRole('group', { name: 'Origin Gateway', exact: true }).count()).toBe(0) + await compareOrRefreshGolden( + fileURLToPath(new URL('./expected/default-model/search.expected.md', import.meta.url)), + await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), + webSnapshotMode(), + ) + await search.press('ArrowDown') const entered = Promise.withResolvers() const release = Promise.withResolvers() const blocked = scaffold.ctx.hmr.runExclusive(async () => { @@ -115,7 +127,7 @@ describe('web e2e: the composer model switch is the default for later sessions', }) try { await entered.promise - await page.getByRole('menuitemradio', { name: 'Acme Large' }).click() + await page.getByRole('menuitemradio', { name: 'Acme Large' }).press('Enter') await expect.poll(() => trigger.getAttribute('aria-busy')).toBe('false') await expect.poll(() => trigger.textContent()).toContain('Acme Large') expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: START_ROUTE, model: START_MODEL }) diff --git a/apps/web/tests/expected/default-model/search.expected.md b/apps/web/tests/expected/default-model/search.expected.md new file mode 100644 index 0000000000..646b890501 --- /dev/null +++ b/apps/web/tests/expected/default-model/search.expected.md @@ -0,0 +1,5 @@ +- menu "模型与推理等级": + - searchbox "搜索模型…": ACMLG + - group "Acme Gateway": + - text: Acme Gateway + - menuitemradio "Acme Large" diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index 65c04a8b64..c15da07a87 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/README.i18n.yaml @@ -15,11 +15,11 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-model-selection/use-this-package: - en: e5a1df3a4dfe5041 - zh: 6269e73026b32a33 + en: 292d778a6bfb4130 + zh: 15009b57f706cfb4 /deepseek-ai-dsh-client-ui-model-selection/use-this-package/model-and-effort: - en: e38d0195677a7b69 - zh: 4eea8a9b1664afd9 + en: 89daf261ce28f29a + zh: c72833dfb8345daf /deepseek-ai-dsh-client-ui-model-selection/use-this-package/unroutable-sessions: en: 7f20428c747ff052 zh: 975e6de728e02fa0 diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index f1da11e94a..118f3be24f 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -31,13 +31,13 @@ DeepSeek account and API-key routes appear as separate provider groups, each exp The unselected model label uses the same regular weight as an available model name and retains the saved reasoning effort caption for existing and new sessions; effort editing requires an available model. Clicking the unselected trigger opens the model list directly; Escape closes it. -Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus across the rows of the shown pane, Tab settles the focused row, and Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling lands on the row of the value in use, and going back lands on the cell that opened the pane left. The composer shows the catalog name while the selected model is available, and its saved `provider/model` ID when the model or provider is removed, including account sign-out. The stored provider, model, and reasoning effort remain unchanged. +Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus across the rows of the shown pane, Tab settles the focused row, and Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling focuses the model search field or the current effort row, and going back lands on the cell that opened the pane left. The composer shows the catalog name while the selected model is available, and its saved `provider/model` ID when the model or provider is removed, including account sign-out. The stored provider, model, and reasoning effort remain unchanged. Mouse selection uses native browser clicks, including their cancellation behavior; a press alone never selects. Opening the menu focuses its trigger, and clicking the trigger again closes the menu and returns focus there. While a selection from either entry is pending, focus stays on the trigger, the trigger shows a spinner in place of its chevron, and each row whose value the selection carries shows one in place of its check; a rejected selection leaves the menu open, and Tab returns to the current row. ### Model and effort -Models stay grouped by provider. The composer menu shows model and effort names only, with DeepSeek Account first and DeepSeek second; third-party providers retain their catalog order. Navigation chevrons use `--dsw-alias-menu-icon`. The `/model` popup shows provider names and catalog descriptions; it localizes the two built-in DeepSeek descriptions and leaves external provider descriptions verbatim. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. +Models stay grouped by provider. The search field at the top of the composer model list matches model names case-insensitively, including nonconsecutive characters in order; leading and trailing spaces are ignored. Empty groups are hidden. Tab or an arrow key moves from search into the results; reopening the model pane clears the query. The composer menu shows model and effort names only, with DeepSeek Account first and DeepSeek second; third-party providers retain their catalog order. Navigation chevrons use `--dsw-alias-menu-icon`. The `/model` popup shows provider names and catalog descriptions; it localizes the two built-in DeepSeek descriptions and leaves external provider descriptions verbatim. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. The composer replaces the model and effort text with the Models icon when the expanded controls cannot share one line, and restores the text when space permits. The full selection remains available in the trigger's accessible name, tooltip, and menu. diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index 42719f4476..dae1316885 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -31,13 +31,13 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示 未选模型文案与可用模型名使用相同的常规字重,并在既有会话与新会话中保留已保存的推理强度显示;调整推理强度需要先选择可用模型。点击未选模型控件直接打开模型列表,Escape 关闭列表。 -与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在所显示面板的行间移动焦点,`Tab` 选定聚焦行,Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻落在正在使用的那一行,返回则落在打开该面板的格子上。所选模型可用时,composer 显示目录中的模型名称;模型或提供方被删除时(包括账号退登),改为显示已保存的 `provider/model` ID。保存的提供方、模型和推理强度均保持不变。 +与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在所显示面板的行间移动焦点,`Tab` 选定聚焦行,Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻聚焦模型搜索框或当前推理强度行,返回则落在打开该面板的格子上。所选模型可用时,composer 显示目录中的模型名称;模型或提供方被删除时(包括账号退登),改为显示已保存的 `provider/model` ID。保存的提供方、模型和推理强度均保持不变。 鼠标选择沿用浏览器原生点击及其取消行为;仅按下按钮不会选定。打开菜单时聚焦触发按钮,再次点击触发按钮会关闭菜单并把焦点还给它。等待任一入口发起的选择结果时,焦点停在触发按钮,触发按钮以加载图标代替下拉箭头,该选择包含的值所在行以加载图标代替勾选标记;选择被拒绝后菜单保持打开,Tab 可回到当前选中的行。 ### 模型与推理强度 -模型按提供方分组。composer 菜单只显示模型与推理强度名称,DeepSeek 账号排第一,DeepSeek 排第二,第三方提供方保持目录原有顺序。导航箭头使用 `--dsw-alias-menu-icon` 文本色。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 +模型按提供方分组。composer 模型列表顶部的搜索框按名称进行不区分大小写的匹配,支持按顺序输入不连续字符,并忽略首尾空格。没有匹配项的分组会隐藏。Tab 或方向键从搜索框进入结果列表;重新打开模型面板会清空搜索内容。composer 菜单只显示模型与推理强度名称,DeepSeek 账号排第一,DeepSeek 排第二,第三方提供方保持目录原有顺序。导航箭头使用 `--dsw-alias-menu-icon` 文本色。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 展开的控件无法排在同一行时,composer 将模型与推理强度文字替换为模型图标;空间足够后恢复文字。触发器的无障碍名称、提示和菜单仍提供完整选择。 diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.module.css b/packages/client/ui-model-selection/src/client/ModelSelect.module.css index 74b3311cdb..ec04efe576 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.module.css +++ b/packages/client/ui-model-selection/src/client/ModelSelect.module.css @@ -155,6 +155,11 @@ cursor: pointer; } +.search { + flex-shrink: 0; + margin: 4px; +} + .groups { min-height: 0; overflow-y: auto; diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index 28e4bfa800..0ce19850eb 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -9,9 +9,10 @@ * step taken while the trigger still holds focus enters at the near end), Tab * settles like Enter, and Escape and Shift+Tab leave a drilled pane first and * otherwise close back to the trigger. A drilled pane hands focus to the row - * of the value in use, and returning to the root pane hands it back to the - * cell that opened it. Data and submission ride the SAME per-session - * ModelDirectory as the /model popup; exact-model reasoning metadata and the + * of the effort in use or the model search field. Model names match a + * case-insensitive ordered subsequence within each provider group. Returning + * to the root pane hands focus back to the cell that opened it. Data and + * submission ride the SAME per-session ModelDirectory as the /model popup; exact-model reasoning metadata and the * selected effort come from the Host rather than a client-owned vocabulary. A * rejected selection announces through the shared transient Toast anchored to * the composer card; the in-menu strip with Retry remains the catalog-load @@ -29,7 +30,7 @@ import clsx from 'clsx' import type { ModelReasoningEffort, ModelSelection } from '@deepseek-ai/dsh-api-remotes/client' import { IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronRightOutlineRegular, - IconDataOutlineRegular, IconWarningOutlineRegular, StateDot, Toast, + IconDataOutlineRegular, IconWarningOutlineRegular, Input, rankByName, StateDot, Toast, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ModelSelectInjected } from './slots.ts' @@ -64,6 +65,7 @@ export function ModelSelect( ) const [open, setOpen] = useState(false) const [pane, setPane] = useState('root') + const [query, setQuery] = useState('') // The in-menu error strip serves catalog loads (its Retry re-runs the // load); a rejected SELECTION announces through the transient toast // instead, so the strip renders only while the latest failure-capable @@ -93,6 +95,9 @@ export function ModelSelect( : { reasoningEffort: model.reasoning.defaultEffort }, } satisfies ModelSelection, }))), [groups]) + const filteredGroups = useMemo(() => groups.map(group => ({ + ...group, models: rankByName(group.models, query.trim()), + })).filter(group => group.models.length > 0), [groups, query]) const selectedIndex = state.current === null ? -1 : choices.findIndex(c => c.selection.provider === state.current?.provider && c.selection.model === state.current.model) @@ -136,17 +141,18 @@ export function ModelSelect( return () => { document.removeEventListener('mousedown', closeOutside) } }, [open]) - // A pane switch unmounts the row that had focus, which drops focus onto the - // page body — outside the card's subtree, where its key handling no longer - // sees a keystroke. Every switch therefore names where the keyboard lands: - // drilling on the pane's current value, coming back on the cell that opened - // the pane left. + // Pane switches unmount the focused row; restore focus inside the menu so + // keyboard navigation remains available. const paneFocus = useRef<'drill' | 'model' | 'effort' | null>(null) useEffect(() => { const intent = paneFocus.current paneFocus.current = null if (!open || intent === null) return if (intent === 'drill') { + if (pane === 'model') { + menuRef.current?.querySelector('input')?.focus() + return + } // The checked row is the value in use; a pane without one opens on its // first row. const checked = menuRef.current?.querySelector('[role="menuitemradio"][aria-checked="true"]:not([disabled])') @@ -191,7 +197,7 @@ export function ModelSelect( window.removeEventListener('scroll', place, true) window.removeEventListener('resize', place) } - }, [open, pane, state]) + }, [open, pane, state, query]) /* jscpd:ignore-end */ if (!available) return null @@ -211,6 +217,7 @@ export function ModelSelect( } const drill = (next: Pane): void => { + setQuery('') paneFocus.current = 'drill' setPane(next) } @@ -235,6 +242,7 @@ export function ModelSelect( } const onRootKeyDown = (event: KeyboardEvent): void => { + if (event.nativeEvent.isComposing) return if (event.key === 'Escape' && open) { event.preventDefault() // Escape backs out of a drilled pane first, then closes. @@ -243,6 +251,11 @@ export function ModelSelect( return } if (!open) return + if (event.key === 'Tab' && !event.shiftKey && event.target instanceof HTMLInputElement) { + event.preventDefault() + moveFocus(1) + return + } // Tab settles like Enter and Shift+Tab leaves like Escape, so the menu's // keys mean what they mean in the composer. Both are consumed: the card // keeps the browser's focus traversal out while it is open. @@ -419,6 +432,14 @@ export function ModelSelect( {pane === 'model' && ( <> + { setQuery(event.target.value) }} + /> {state.status === 'loading' && (
{t('status.loading')}
)} @@ -435,7 +456,7 @@ export function ModelSelect( ))}
- {groups.map((group) => { + {filteredGroups.map((group) => { const headingId = `${id}-${group.id}` return (
@@ -469,8 +490,8 @@ export function ModelSelect( ) })}
- {state.status === 'ready' && choices.length === 0 && ( -
{t('empty.models')}
+ {state.status === 'ready' && filteredGroups.length === 0 && ( +
{t(choices.length === 0 ? 'empty.models' : 'search.empty')}
)} )} diff --git a/packages/client/ui-model-selection/src/client/locales.ts b/packages/client/ui-model-selection/src/client/locales.ts index bb61b7e931..b5e142a476 100644 --- a/packages/client/ui-model-selection/src/client/locales.ts +++ b/packages/client/ui-model-selection/src/client/locales.ts @@ -30,6 +30,8 @@ export const zh = { 'error.sessionInUse': '当前会话已被占用,可能是其他正在运行的 DSH 导致的(如其他 dsh web、桌面端),请退出其他正在运行的 DSH 后重试。', 'action.reload': '重新加载', 'warning.groupLoad': '{name} 加载失败:{message}', + 'search.placeholder': '搜索模型…', + 'search.empty': '没有匹配的模型。', 'empty.models': '没有可用的模型。', 'empty.efforts': '当前模型未提供推理等级。', } satisfies Record @@ -59,6 +61,8 @@ export const en = { 'error.sessionInUse': 'This session is already in use, possibly by another running DSH instance (such as dsh web or the desktop app). Quit other running DSH instances and try again.', 'action.reload': 'Reload', 'warning.groupLoad': '{name} failed to load: {message}', + 'search.placeholder': 'Search models…', + 'search.empty': 'No matching models.', 'empty.models': 'No models available.', 'empty.efforts': 'This model provides no reasoning effort levels.', } satisfies Record diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index aafc5b98a9..bbfbbc919d 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -469,9 +469,7 @@ describe('ModelSelect keyboard walk', () => { const trigger = screen.getByRole('button', { name: /选择模型/ }) fireEvent.click(trigger) fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) - // No rows to hand the keyboard to: the trigger keeps it, so the card's - // keys still reach the menu. - expect(document.activeElement).toBe(trigger) + expect(document.activeElement).toBe(screen.getByRole('searchbox')) const retry = screen.getByRole('button', { name: '重试' }) expect(fireEvent.mouseDown(retry)).toBe(false) @@ -489,12 +487,12 @@ describe('ModelSelect keyboard walk', () => { expect(screen.queryByRole('menu')).toBeNull() }) - it('drills into the model list on the selected model', () => { + it('focuses search while retaining the checked model', () => { mountOpen() fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) const rows = screen.getAllByRole('menuitemradio') expect(rows[0]!.getAttribute('aria-checked')).toBe('true') - expect(document.activeElement).toBe(rows[0]) + expect(document.activeElement).toBe(screen.getByRole('searchbox')) }) it('Escape returns to the root pane with the keyboard on the cell that drilled in', () => { @@ -520,7 +518,7 @@ describe('ModelSelect keyboard walk', () => { expect(document.activeElement).toBe(cells[0]) }) - it('a pane whose rows mark no current value opens on its first row', () => { + it('focuses search when no model is checked', () => { // The session runs a model the catalog no longer lists: no row is checked. render( { fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) const rows = screen.getAllByRole('menuitemradio') expect(rows.every(row => row.getAttribute('aria-checked') === 'false')).toBe(true) - expect(document.activeElement).toBe(rows[0]) + expect(document.activeElement).toBe(screen.getByRole('searchbox')) + }) +}) + +describe('ModelSelect search', () => { + it('filters model names fuzzily, hides empty groups, clears on reopening, and selects a result', async () => { + const directory = createSnapshotStore(state({ groups: [ + ...state().groups, + { id: 'other', name: 'Other', models: [{ id: 'gemini', name: 'Gemini Flash' }] }, + ] })) + const select = vi.fn().mockResolvedValue({ ok: true, value: undefined }) + render() + const trigger = screen.getByRole('button', { name: /选择模型/ }) + fireEvent.click(trigger) + fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) + const search = screen.getByRole('searchbox') + expect(document.activeElement).toBe(search) + expect(fireEvent.keyDown(search, { key: 'ArrowDown', isComposing: true })).toBe(true) + expect(document.activeElement).toBe(search) + fireEvent.change(search, { target: { value: ' GMFL ' } }) + expect(screen.getAllByRole('menuitemradio').map(row => row.textContent)).toEqual(['Gemini Flash']) + expect(screen.queryByRole('group', { name: 'DeepSeek' })).toBeNull() + expect(trigger.textContent).toContain('DeepSeek-V4-Flash') + fireEvent.change(search, { target: { value: 'zzzz' } }) + expect(screen.getByRole('status').textContent).toBe('没有匹配的模型。') + expect(fireEvent.keyDown(search, { key: 'ArrowDown' })).toBe(false) + expect(document.activeElement).toBe(search) + fireEvent.change(search, { target: { value: '' } }) + expect(screen.getAllByRole('menuitemradio')).toHaveLength(2) + fireEvent.change(search, { target: { value: 'gmfl' } }) + fireEvent.keyDown(search, { key: 'Escape' }) + fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) + expect(screen.getByRole('searchbox').getAttribute('value')).toBe('') + const reopened = screen.getByRole('searchbox') + fireEvent.change(reopened, { target: { value: 'gmfl' } }) + fireEvent.keyDown(reopened, { key: 'Tab' }) + const row = screen.getByRole('menuitemradio', { name: 'Gemini Flash' }) + expect(document.activeElement).toBe(row) + fireEvent.keyDown(row, { key: 'Tab' }) + await waitFor(() => { expect(screen.queryByRole('menu')).toBeNull() }) + expect(select).toHaveBeenCalledWith({ provider: 'other', model: 'gemini' }) }) }) From b29578b8f0f4e2ed8ae8e1ce491764dae4bd40b2 Mon Sep 17 00:00:00 2001 From: Yao Meng Date: Mon, 21 Sep 2026 14:50:38 +0800 Subject: [PATCH 10/42] style(web): soften model search field and add leading icon Signed-off-by: Yao Meng --- .../src/client/ModelSelect.module.css | 18 ++++++++++++++++-- .../src/client/ModelSelect.tsx | 3 ++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.module.css b/packages/client/ui-model-selection/src/client/ModelSelect.module.css index ec04efe576..7f57d8371a 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.module.css +++ b/packages/client/ui-model-selection/src/client/ModelSelect.module.css @@ -155,9 +155,23 @@ cursor: pointer; } -.search { +.menu .search { flex-shrink: 0; - margin: 4px; + gap: 8px; + height: 34px; + margin: 6px; + padding: 0 10px; + border: none; + border-radius: 10px; + background: var(--dsw-alias-bg-module-platform); +} + +.search:focus-within { + box-shadow: inset 0 0 0 1px var(--dsw-alias-border-l2); +} + +.search input { + font-size: 13px; } .groups { diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index 0ce19850eb..c8a5ff76c9 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -30,7 +30,7 @@ import clsx from 'clsx' import type { ModelReasoningEffort, ModelSelection } from '@deepseek-ai/dsh-api-remotes/client' import { IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronRightOutlineRegular, - IconDataOutlineRegular, IconWarningOutlineRegular, Input, rankByName, StateDot, Toast, + IconDataOutlineRegular, IconSearchOutlineRegular, IconWarningOutlineRegular, Input, rankByName, StateDot, Toast, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ModelSelectInjected } from './slots.ts' @@ -433,6 +433,7 @@ export function ModelSelect( {pane === 'model' && ( <> } className={clsx(css.search)} type="search" aria-label={t('search.placeholder')} From 4ff33be0bdc291832a177dda3fc08aecfd4a1135 Mon Sep 17 00:00:00 2001 From: Yao Meng Date: Mon, 21 Sep 2026 14:52:02 +0800 Subject: [PATCH 11/42] test(web): update provider model picker search snapshot Signed-off-by: Yao Meng --- .../tests/expected/deepseek-messages-settings/picker.expected.md | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md b/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md index 37066e17e2..25ead71098 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md @@ -1,4 +1,5 @@ - menu "模型与推理等级": + - searchbox "搜索模型…" - group "DeepSeek": - text: DeepSeek - menuitemradio "Messages Flash" [checked] From b3d6da908332f9bdb145b5f8b4e4bae7c3ce9332 Mon Sep 17 00:00:00 2001 From: Yao Meng Date: Mon, 21 Sep 2026 15:23:18 +0800 Subject: [PATCH 12/42] fix(web): address model search accessibility review Signed-off-by: Yao Meng --- .../tests/deepseek-messages-settings.e2e.ts | 2 +- apps/web/tests/default-model.e2e.ts | 6 ++-- .../picker.expected.md | 11 +++---- .../expected/default-model/search.expected.md | 9 +++--- .../ui-model-selection/README.i18n.yaml | 4 +-- packages/client/ui-model-selection/README.md | 2 +- .../client/ui-model-selection/README.zh.md | 2 +- .../src/client/ModelSelect.tsx | 29 ++++++++++++------- .../tests/model-select.client.spec.tsx | 7 ++++- 9 files changed, 44 insertions(+), 28 deletions(-) diff --git a/apps/web/tests/deepseek-messages-settings.e2e.ts b/apps/web/tests/deepseek-messages-settings.e2e.ts index 6543428836..0b2ebfb531 100644 --- a/apps/web/tests/deepseek-messages-settings.e2e.ts +++ b/apps/web/tests/deepseek-messages-settings.e2e.ts @@ -80,7 +80,7 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages sett await page.getByRole('menuitem', { name: /模型/ }).click() await page.getByRole('menuitemradio', { name: 'Messages Flash', exact: true }).waitFor() await compareOrRefreshGolden(join(EXPECTED, 'picker.expected.md'), - await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), webSnapshotMode()) + await captureStableAria(page, '[role="group"][aria-label="模型与推理等级"]', scaffold.workspaceCwd), webSnapshotMode()) expect(tripwire.pageErrors).toEqual([]) }, 60_000) diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index 88610f68ec..fad2f1a182 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -115,10 +115,12 @@ describe('web e2e: the composer model switch is the default for later sessions', expect(await page.getByRole('group', { name: 'Origin Gateway', exact: true }).count()).toBe(0) await compareOrRefreshGolden( fileURLToPath(new URL('./expected/default-model/search.expected.md', import.meta.url)), - await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), + await captureStableAria(page, '[role="group"][aria-label="模型与推理等级"]', scaffold.workspaceCwd), webSnapshotMode(), ) await search.press('ArrowDown') + await expect.poll(() => page.getByRole('menuitemradio', { name: 'Acme Large' }) + .evaluate(row => row === row.ownerDocument.activeElement)).toBe(true) const entered = Promise.withResolvers() const release = Promise.withResolvers() const blocked = scaffold.ctx.hmr.runExclusive(async () => { @@ -127,7 +129,7 @@ describe('web e2e: the composer model switch is the default for later sessions', }) try { await entered.promise - await page.getByRole('menuitemradio', { name: 'Acme Large' }).press('Enter') + await page.keyboard.press('Enter') await expect.poll(() => trigger.getAttribute('aria-busy')).toBe('false') await expect.poll(() => trigger.textContent()).toContain('Acme Large') expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: START_ROUTE, model: START_MODEL }) diff --git a/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md b/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md index 25ead71098..ab0219c316 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md @@ -1,6 +1,7 @@ -- menu "模型与推理等级": +- group "模型与推理等级": - searchbox "搜索模型…" - - group "DeepSeek": - - text: DeepSeek - - menuitemradio "Messages Flash" [checked] - - menuitemradio "DeepSeek-V4-Pro" + - menu "模型": + - group "DeepSeek": + - text: DeepSeek + - menuitemradio "Messages Flash" [checked] + - menuitemradio "DeepSeek-V4-Pro" diff --git a/apps/web/tests/expected/default-model/search.expected.md b/apps/web/tests/expected/default-model/search.expected.md index 646b890501..457cc4d5f9 100644 --- a/apps/web/tests/expected/default-model/search.expected.md +++ b/apps/web/tests/expected/default-model/search.expected.md @@ -1,5 +1,6 @@ -- menu "模型与推理等级": +- group "模型与推理等级": - searchbox "搜索模型…": ACMLG - - group "Acme Gateway": - - text: Acme Gateway - - menuitemradio "Acme Large" + - menu "模型": + - group "Acme Gateway": + - text: Acme Gateway + - menuitemradio "Acme Large" diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index c15da07a87..31d9628090 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/README.i18n.yaml @@ -18,8 +18,8 @@ en: 292d778a6bfb4130 zh: 15009b57f706cfb4 /deepseek-ai-dsh-client-ui-model-selection/use-this-package/model-and-effort: - en: 89daf261ce28f29a - zh: c72833dfb8345daf + en: 4cf17d5a20e43800 + zh: 2e865599fbf6c591 /deepseek-ai-dsh-client-ui-model-selection/use-this-package/unroutable-sessions: en: 7f20428c747ff052 zh: 975e6de728e02fa0 diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index 118f3be24f..ceb36aac1e 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -37,7 +37,7 @@ Mouse selection uses native browser clicks, including their cancellation behavio ### Model and effort -Models stay grouped by provider. The search field at the top of the composer model list matches model names case-insensitively, including nonconsecutive characters in order; leading and trailing spaces are ignored. Empty groups are hidden. Tab or an arrow key moves from search into the results; reopening the model pane clears the query. The composer menu shows model and effort names only, with DeepSeek Account first and DeepSeek second; third-party providers retain their catalog order. Navigation chevrons use `--dsw-alias-menu-icon`. The `/model` popup shows provider names and catalog descriptions; it localizes the two built-in DeepSeek descriptions and leaves external provider descriptions verbatim. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. +Models stay grouped by provider. The search field at the top of the composer model list matches model names case-insensitively, including nonconsecutive characters in order; leading and trailing spaces are ignored. Within each provider, prefix matches come first, followed by alignment score and then catalog order. Empty groups are hidden. Tab or an arrow key moves from search into the results; reopening the model pane clears the query. The composer menu shows model and effort names only, with DeepSeek Account first and DeepSeek second; third-party providers retain their catalog order. Navigation chevrons use `--dsw-alias-menu-icon`. The `/model` popup shows provider names and catalog descriptions; it localizes the two built-in DeepSeek descriptions and leaves external provider descriptions verbatim. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. The composer replaces the model and effort text with the Models icon when the expanded controls cannot share one line, and restores the text when space permits. The full selection remains available in the trigger's accessible name, tooltip, and menu. diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index dae1316885..70e28bf96b 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -37,7 +37,7 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示 ### 模型与推理强度 -模型按提供方分组。composer 模型列表顶部的搜索框按名称进行不区分大小写的匹配,支持按顺序输入不连续字符,并忽略首尾空格。没有匹配项的分组会隐藏。Tab 或方向键从搜索框进入结果列表;重新打开模型面板会清空搜索内容。composer 菜单只显示模型与推理强度名称,DeepSeek 账号排第一,DeepSeek 排第二,第三方提供方保持目录原有顺序。导航箭头使用 `--dsw-alias-menu-icon` 文本色。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 +模型按提供方分组。composer 模型列表顶部的搜索框按名称进行不区分大小写的匹配,支持按顺序输入不连续字符,并忽略首尾空格。每个提供方内的结果优先按前缀匹配、其次按匹配得分、最后按目录顺序排列。没有匹配项的分组会隐藏。Tab 或方向键从搜索框进入结果列表;重新打开模型面板会清空搜索内容。composer 菜单只显示模型与推理强度名称,DeepSeek 账号排第一,DeepSeek 排第二,第三方提供方保持目录原有顺序。导航箭头使用 `--dsw-alias-menu-icon` 文本色。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 展开的控件无法排在同一行时,composer 将模型与推理强度文字替换为模型图标;空间足够后恢复文字。触发器的无障碍名称、提示和菜单仍提供完整选择。 diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index c8a5ff76c9..6800fe7f10 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -10,15 +10,17 @@ * settles like Enter, and Escape and Shift+Tab leave a drilled pane first and * otherwise close back to the trigger. A drilled pane hands focus to the row * of the effort in use or the model search field. Model names match a - * case-insensitive ordered subsequence within each provider group. Returning - * to the root pane hands focus back to the cell that opened it. Data and - * submission ride the SAME per-session ModelDirectory as the /model popup; exact-model reasoning metadata and the - * selected effort come from the Host rather than a client-owned vocabulary. A - * rejected selection announces through the shared transient Toast anchored to - * the composer card; the in-menu strip with Retry remains the catalog-load - * surface. While the directory's pending selection is unsettled, the trigger - * shows a spinner in place of its chevron, and each row whose value that - * selection carries shows one in place of its check mark. + * case-insensitive ordered subsequence within each provider group, ranked by + * prefix, alignment score, then catalog order. Returning to the root pane + * hands focus back to the cell that opened it. Data and submission ride the + * same per-session ModelDirectory as the /model popup; exact-model reasoning + * metadata and the selected effort come from the Host rather than a + * client-owned vocabulary. A rejected selection announces through the shared + * transient Toast anchored to the composer card; the in-menu strip with + * Retry remains the catalog-load surface. While the directory's pending + * selection is unsettled, the trigger shows a spinner in place of its + * chevron, and each row whose value that selection carries shows one in place + * of its check mark. */ import { MenuSurface } from '@deepseek-ai/dsh-client-ui-primitives' import { @@ -409,7 +411,7 @@ export function ModelSelect( id={`${id}-menu`} className={css.menu} style={menuPos ?? MEASURE_STYLE} - role="menu" + role={pane === 'model' ? 'group' : 'menu'} aria-label={t('menu.aria')} aria-busy={state.status === 'loading' || busy} > @@ -456,7 +458,12 @@ export function ModelSelect( ))} -
+ ))} +
} {state.status === 'loading' && (
{t('status.loading')}
)} @@ -571,12 +595,16 @@ export function ModelSelect( role="menuitemradio" aria-checked={selected} id={`${id}-model-${index}`} - tabIndex={-1} + tabIndex={showSearch ? -1 : 0} + onFocus={() => { setHighlightedIndex(index) }} data-highlighted={index === activeModelIndex ? '' : undefined} className={clsx( css.option, css.modelOption, selected && css.selected, index === activeModelIndex && css.optionActive, )} - onMouseMove={busy || index === activeModelIndex ? undefined : () => { setHighlightedIndex(index) }} + onMouseMove={busy || index === activeModelIndex ? undefined : () => { + if (showSearch) setHighlightedIndex(index) + else itemRefs.current[index]?.focus() + }} key={model.id} title={model.name} disabled={busy} diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index 38c87c6a4a..0341daa436 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -53,6 +53,16 @@ function state(overrides: Partial = {}): ModelDirectoryStat } } +function modelGroups(count: number): ModelDirectoryState['groups'] { + const group = state().groups[0]! + return [{ ...group, models: [ + ...group.models, + ...Array.from({ length: Math.max(0, count - 1) }, (_, index) => ({ + id: `model-${index + 2}`, name: `Model ${index + 2}`, + })), + ].slice(0, count) }] +} + const scrollIntoView = vi.fn() beforeEach(() => { scrollIntoView.mockClear() @@ -236,10 +246,8 @@ describe('ModelSelect reasoning effort', () => { // The selection failure does not render the in-menu load strip (no Retry). expect(screen.queryByRole('button', { name: '重试' })).toBeNull() fireEvent.keyDown(trigger, { key: 'Tab' }) - const search = screen.getByRole('searchbox') - expect(document.activeElement).toBe(search) - expect(search.getAttribute('aria-activedescendant')) - .toBe(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' }).id) + expect(screen.queryByRole('searchbox')).toBeNull() + expect(document.activeElement).toBe(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })) }) it('spins on the trigger and the chosen model row until the selection settles, across pane changes', async () => { @@ -487,13 +495,14 @@ describe('ModelSelect keyboard walk', () => { const trigger = screen.getByRole('button', { name: /选择模型/ }) fireEvent.click(trigger) fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) - expect(document.activeElement).toBe(screen.getByRole('searchbox')) + expect(screen.queryByRole('searchbox')).toBeNull() + expect(document.activeElement).toBe(trigger) const retry = screen.getByRole('button', { name: '重试' }) expect(fireEvent.mouseDown(retry)).toBe(false) fireEvent.click(retry) expect(load).toHaveBeenCalledTimes(2) - expect(screen.getByRole('menu')).toBeTruthy() + expect(screen.getByRole('group', { name: '模型与推理等级' })).toBeTruthy() retry.focus() // A control that is not a row keeps the browser's traversal. expect(fireEvent.keyDown(retry, { key: 'Tab' })).toBe(true) @@ -505,12 +514,13 @@ describe('ModelSelect keyboard walk', () => { expect(screen.queryByRole('menu')).toBeNull() }) - it('focuses search while retaining the checked model', () => { + it('focuses the checked model in a small catalog', () => { mountOpen() fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) const rows = screen.getAllByRole('menuitemradio') expect(rows[0]!.getAttribute('aria-checked')).toBe('true') - expect(document.activeElement).toBe(screen.getByRole('searchbox')) + expect(screen.queryByRole('searchbox')).toBeNull() + expect(document.activeElement).toBe(rows[0]) }) it('Escape returns to the root pane with the keyboard on the cell that drilled in', () => { @@ -536,7 +546,7 @@ describe('ModelSelect keyboard walk', () => { expect(document.activeElement).toBe(cells[0]) }) - it('focuses search when no model is checked', () => { + it('focuses the first row when no model is checked in a small catalog', () => { // The session runs a model the catalog no longer lists: no row is checked. render( { fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) const rows = screen.getAllByRole('menuitemradio') expect(rows.every(row => row.getAttribute('aria-checked') === 'false')).toBe(true) - expect(document.activeElement).toBe(screen.getByRole('searchbox')) + expect(screen.queryByRole('searchbox')).toBeNull() + expect(document.activeElement).toBe(rows[0]) + }) +}) + +describe('ModelSelect catalog size', () => { + it.each([0, 1, 4, 5])('shows search only above four models (%i models)', (count) => { + const groups = modelGroups(count) + const current = { provider: 'deepseek-official', model: count > 1 ? 'model-2' : 'deepseek-v4-flash' } + render() + const trigger = screen.getByRole('button', { name: /选择模型/ }) + fireEvent.click(trigger) + fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) + const rows = screen.queryAllByRole('menuitemradio') + expect(rows).toHaveLength(count) + if (count > 4) { + const search = screen.getByRole('searchbox') + expect(search).toBeInstanceOf(HTMLInputElement) + expect(document.activeElement).toBe(search) + expect(search.getAttribute('aria-activedescendant')).toBe(rows[1]!.id) + expect(rows.every(row => row.tabIndex === -1)).toBe(true) + } else { + expect(screen.queryByRole('searchbox')).toBeNull() + expect(document.activeElement).toBe(count === 0 ? trigger : rows[count > 1 ? 1 : 0]) + expect(rows.every(row => row.tabIndex === 0)).toBe(true) + } + if (count === 0) expect(screen.getByRole('status').textContent).toBe(zh['empty.models']) + }) + + it.each([0, 1, 4, 5])('opens an unselected %i-model catalog on search or its first row', (count) => { + render() + const trigger = screen.getByRole('button', { name: '请选择模型' }) + fireEvent.click(trigger) + const rows = screen.queryAllByRole('menuitemradio') + expect(rows).toHaveLength(count) + expect(rows.every(row => row.getAttribute('aria-checked') === 'false')).toBe(true) + expect(document.activeElement).toBe(count > 4 ? screen.getByRole('searchbox') : rows[0] ?? trigger) + if (count <= 4) expect(screen.queryByRole('searchbox')).toBeNull() + }) + + it.each([true, false])('clears search and restores focus across 5 → 4 → 5 models (checked: %s)', (checked) => { + const current = { provider: 'deepseek-official', model: checked ? 'model-3' : 'removed' } + const directory = createSnapshotStore(state({ groups: modelGroups(5), current })) + render() + fireEvent.click(screen.getByRole('button', { name: /选择模型/ })) + fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) + const search = screen.getByRole('searchbox') + fireEvent.change(search, { target: { value: 'Model 5' } }) + expect(screen.getAllByRole('menuitemradio')).toHaveLength(1) + expect(screen.getByRole('searchbox')).toBe(search) + expect(document.activeElement).toBe(search) + + act(() => { directory.set(state({ groups: modelGroups(4), current })) }) + expect(screen.queryByRole('searchbox')).toBeNull() + expect(screen.queryByRole('button', { name: '清除搜索' })).toBeNull() + const rows = screen.getAllByRole('menuitemradio') + expect(rows).toHaveLength(4) + expect(document.activeElement).toBe(rows[checked ? 2 : 0]) + + act(() => { directory.set(state({ groups: modelGroups(5), current })) }) + const restored = screen.getByRole('searchbox') + expect(restored.getAttribute('value')).toBe('') + expect(document.activeElement).toBe(restored) + expect(screen.getAllByRole('menuitemradio')).toHaveLength(5) + expect(restored.getAttribute('aria-activedescendant')) + .toBe(screen.getAllByRole('menuitemradio')[checked ? 2 : 0]!.id) + }) + + it('moves actual row focus with arrows, leaves Enter native, and selects with Tab in a small catalog', async () => { + const select = vi.fn().mockResolvedValue({ ok: true, value: undefined }) + render() + const trigger = screen.getByRole('button', { name: /选择模型/ }) + fireEvent.click(trigger) + fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) + const rows = screen.getAllByRole('menuitemradio') + expect(document.activeElement).toBe(rows[1]) + expect(fireEvent.keyDown(rows[1]!, { key: 'ArrowDown' })).toBe(false) + expect(document.activeElement).toBe(rows[2]) + fireEvent.keyDown(rows[2]!, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(rows[3]) + fireEvent.keyDown(rows[3]!, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(rows[0]) + fireEvent.keyDown(rows[0]!, { key: 'ArrowUp' }) + expect(document.activeElement).toBe(rows[3]) + fireEvent.keyDown(rows[3]!, { key: 'ArrowUp' }) + expect(document.activeElement).toBe(rows[2]) + expect(rows[2]!.hasAttribute('data-highlighted')).toBe(true) + expect(fireEvent.keyDown(rows[2]!, { key: 'Enter' })).toBe(true) + expect(fireEvent.keyDown(rows[2]!, { key: 'ArrowLeft' })).toBe(true) + expect(document.activeElement).toBe(rows[2]) + expect(select).not.toHaveBeenCalled() + expect(fireEvent.keyDown(rows[2]!, { key: 'Tab' })).toBe(false) + expect(select).toHaveBeenCalledWith({ provider: 'deepseek-official', model: 'model-3' }) + await waitFor(() => { expect(document.activeElement).toBe(trigger) }) + expect(screen.queryByRole('group', { name: '模型与推理等级' })).toBeNull() + expect(trigger.hasAttribute('data-selection-focus')).toBe(true) + }) + + it('keeps Tab selection available after hovering a small-catalog row', async () => { + const select = vi.fn().mockResolvedValue({ ok: true, value: undefined }) + render() + fireEvent.click(screen.getByRole('button', { name: /选择模型/ })) + fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) + const row = screen.getByRole('menuitemradio', { name: 'Model 3' }) + fireEvent.mouseMove(row) + expect(document.activeElement).toBe(row) + expect(row.hasAttribute('data-highlighted')).toBe(true) + expect(fireEvent.keyDown(row, { key: 'Tab' })).toBe(false) + expect(select).toHaveBeenCalledWith({ provider: 'deepseek-official', model: 'model-3' }) + await waitFor(() => { expect(screen.queryByRole('group', { name: '模型与推理等级' })).toBeNull() }) + }) + + it.each(['Escape', 'Tab'])('leaves a small model pane with %s and returns to its root cell', (key) => { + const select = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: /选择模型/ })) + fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ })) + const row = screen.getAllByRole('menuitemradio')[0]! + expect(fireEvent.keyDown(row, { key, shiftKey: key === 'Tab' })).toBe(false) + const cell = screen.getByRole('menuitem', { name: /^模型/ }) + expect(document.activeElement).toBe(cell) + expect(select).not.toHaveBeenCalled() + fireEvent.keyDown(cell, { key, shiftKey: key === 'Tab' }) + expect(screen.queryByRole('menu')).toBeNull() }) }) describe('ModelSelect search', () => { + it('clears search when reopening an unselected large catalog', () => { + render() + const trigger = screen.getByRole('button', { name: '请选择模型' }) + fireEvent.click(trigger) + const search = screen.getByRole('searchbox') + expect(document.activeElement).toBe(search) + fireEvent.change(search, { target: { value: 'zzzz' } }) + fireEvent.keyDown(search, { key: 'Escape' }) + expect(screen.queryByRole('group', { name: '模型与推理等级' })).toBeNull() + fireEvent.click(trigger) + expect(screen.getByRole('searchbox').getAttribute('value')).toBe('') + expect(document.activeElement).toBe(screen.getByRole('searchbox')) + expect(screen.getAllByRole('menuitemradio')).toHaveLength(5) + }) + + it('hides empty provider headings and announces an empty catalog', () => { + const directory = createSnapshotStore(state({ groups: [ + ...state().groups, { id: 'empty', name: 'Empty Provider', models: [] }, + ] })) + render() + fireEvent.click(screen.getByRole('button', { name: /选择模型/ })) + fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) + expect(screen.queryByRole('group', { name: 'Empty Provider' })).toBeNull() + act(() => { directory.set(state({ groups: [] })) }) + expect(screen.getByRole('status').textContent).toBe(zh['empty.models']) + expect(screen.getByRole('status').closest('[role="menu"]')).toBeNull() + }) + + it.each(['Enter', 'Tab'])('returns focus silently after %s accepts the current model, without suppressing later focus', async (key) => { + const select = vi.fn() + render(<> + + + ) + const trigger = screen.getByRole('button', { name: /选择模型/ }) + fireEvent.click(trigger) + fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) + fireEvent.keyDown(screen.getByRole('searchbox'), { key }) + await waitFor(() => { expect(document.activeElement).toBe(trigger) }) + expect(trigger.hasAttribute('data-selection-focus')).toBe(true) + expect(select).not.toHaveBeenCalled() + act(() => { screen.getByRole('button', { name: 'Outside' }).focus() }) + expect(trigger.hasAttribute('data-selection-focus')).toBe(false) + act(() => { trigger.focus() }) + expect(trigger.hasAttribute('data-selection-focus')).toBe(false) + }) + it.each(['Enter', 'Tab'])('keeps typing focus while arrows wrap across groups and %s accepts the highlight', async (key) => { const select = vi.fn().mockResolvedValue({ ok: true, value: undefined }) const directory = createSnapshotStore(state({ current: { provider: 'deepseek-official', model: 'beta' }, groups: [ { id: 'deepseek-official', name: 'DeepSeek', models: [{ id: 'alpha', name: 'Alpha' }, { id: 'beta', name: 'Beta' }] }, - { id: 'other', name: 'Other', models: [{ id: 'gamma', name: 'Gamma' }] }, + { id: 'other', name: 'Other', models: [ + { id: 'delta', name: 'Delta' }, { id: 'epsilon', name: 'Epsilon' }, { id: 'gamma', name: 'Gamma' }, + ] }, ], })) render() fireEvent.click(screen.getByRole('button', { name: /选择模型/ })) fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) const search = screen.getByRole('searchbox') - const [alpha, beta, gamma] = screen.getAllByRole('menuitemradio') + expect(search).toBeInstanceOf(HTMLInputElement) + const [alpha, beta, delta, epsilon, gamma] = screen.getAllByRole('menuitemradio') expect(search.getAttribute('aria-activedescendant')).toBe(beta!.id) fireEvent.keyDown(search, { key: 'ArrowDown' }) + expect(search.getAttribute('aria-activedescendant')).toBe(delta!.id) + fireEvent.keyDown(search, { key: 'ArrowDown' }) + expect(search.getAttribute('aria-activedescendant')).toBe(epsilon!.id) + fireEvent.keyDown(search, { key: 'ArrowDown' }) expect(search.getAttribute('aria-activedescendant')).toBe(gamma!.id) expect(document.activeElement).toBe(search) fireEvent.keyDown(search, { key: 'ArrowDown' }) @@ -594,17 +791,20 @@ describe('ModelSelect search', () => { fireEvent.click(screen.getByRole('button', { name: '清除搜索' })) const restored = screen.getAllByRole('menuitemradio') expect(search.getAttribute('aria-activedescendant')).toBe(restored[0]!.id) - fireEvent.mouseMove(restored[2]!) - expect(search.getAttribute('aria-activedescendant')).toBe(restored[2]!.id) + fireEvent.mouseMove(restored[4]!) + expect(search.getAttribute('aria-activedescendant')).toBe(restored[4]!.id) expect(document.activeElement).toBe(search) fireEvent.keyDown(search, { key }) expect(select).toHaveBeenCalledWith({ provider: 'other', model: 'gamma' }) await waitFor(() => { expect(screen.queryByRole('group', { name: '模型与推理等级' })).toBeNull() }) + const trigger = screen.getByRole('button', { name: /选择模型/ }) + await waitFor(() => { expect(document.activeElement).toBe(trigger) }) + expect(trigger.hasAttribute('data-selection-focus')).toBe(true) }) it('filters model names fuzzily, hides empty groups, clears on reopening, and selects a result', async () => { const directory = createSnapshotStore(state({ groups: [ - ...state().groups, + ...modelGroups(4), { id: 'other', name: 'Other', models: [{ id: 'gemini', name: 'Gemini Flash' }] }, ] })) const select = vi.fn().mockResolvedValue({ ok: true, value: undefined }) @@ -619,6 +819,8 @@ describe('ModelSelect search', () => { expect(document.activeElement).toBe(search) fireEvent.change(search, { target: { value: ' GMFL ' } }) expect(screen.getAllByRole('menuitemradio').map(row => row.textContent)).toEqual(['Gemini Flash']) + expect(screen.getByRole('searchbox')).toBe(search) + expect(document.activeElement).toBe(search) expect(screen.queryByRole('group', { name: 'DeepSeek' })).toBeNull() expect(trigger.textContent).toContain('DeepSeek-V4-Flash') fireEvent.change(search, { target: { value: 'zzzz' } }) @@ -632,7 +834,7 @@ describe('ModelSelect search', () => { expect(search.getAttribute('value')).toBe('') expect(document.activeElement).toBe(search) expect(screen.queryByRole('button', { name: '清除搜索' })).toBeNull() - expect(screen.getAllByRole('menuitemradio')).toHaveLength(2) + expect(screen.getAllByRole('menuitemradio')).toHaveLength(5) fireEvent.change(search, { target: { value: 'gmfl' } }) fireEvent.keyDown(search, { key: 'Escape' }) fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) @@ -699,10 +901,15 @@ it('shows the unselected model control with the inherited effort', async () => { expect(trigger.textContent).toContain('High') fireEvent.click(trigger) expect(screen.queryByRole('menuitem', { name: /模型/ })).toBeNull() - const model = screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' }) - expect(document.activeElement).toBe(model) - fireEvent.keyDown(model, { key: 'Escape' }) - expect(screen.queryByRole('menu')).toBeNull() + expect(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })).toBeTruthy() + expect(screen.queryByRole('searchbox')).toBeNull() + const row = screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' }) + expect(document.activeElement).toBe(row) + fireEvent.keyDown(row, { key: 'Escape' }) + expect(screen.queryByRole('group', { name: '模型与推理等级' })).toBeNull() + fireEvent.click(trigger) + expect(screen.queryByRole('searchbox')).toBeNull() + expect(document.activeElement).toBe(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })) }) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index a971b9e834..6b157578fb 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -18,8 +18,8 @@ en: 7bae72c97d0449f5 zh: 4589ecc2b216edc5 /deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog: - en: b521f773eff5c728 - zh: 7b56c5b4777a0b00 + en: c4d22d4642ec6fe0 + zh: 27c4dcae204d1f8f /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: en: b4cd33720a6cf6b8 zh: 8db75bf633789c6d diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 663832fe07..f69307078a 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -44,7 +44,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `Switch` | Two-state toggle, 36×20. The off-state thumb reads `--dsw-alias-switch-thumb` so it stays light in both themes, and the on-state thumb contrasts with the brand track. Disabled controls use half opacity. `label` is required, so the control cannot ship unnamed. | | `SegmentedControl` | Tablist of two or more equal-width segments with one sliding indicator, for switching a card or panel between a few modes; the owner holds the selection and `label` names the list. `id` seeds each tab's id (`-`) and the panel it controls (`--panel`), which the owner renders and points back at the tab with `aria-labelledby`; a segment may be `disabled` with a `title`, and `disabled` on the control locks every segment while the shown panel has work in flight. | | `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. | -| `Input` | Single-line text entry for search boxes and inline forms. | +| `Input` | Single-line text entry for search boxes and inline forms. Its ref targets the native input for focus and is cleared on unmount. | | `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. | | `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. | | `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 03b43c609a..cfc7653184 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -44,7 +44,7 @@ kind: "package-library" | `Switch` | 36×20 的双态开关。关闭态滑块读取 `--dsw-alias-switch-thumb`,在两种主题下均保持亮色;开启态滑块与品牌色轨道形成对比。禁用控件使用半透明样式。`label` 必填,控件不可能在没有名称的情况下发布。 | | `SegmentedControl` | 两段或更多等宽分段加一个滑动指示块的 tablist,用于在几种模式间切换一张卡片或面板;选中项由调用方持有,`label` 为列表命名。`id` 派生每个 tab 的 id(`-`)及其控制的面板 id(`--panel`),面板由调用方渲染并用 `aria-labelledby` 指回 tab;分段可 `disabled` 并带 `title`,控件级 `disabled` 在当前面板有进行中的操作时锁住全部分段。 | | `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 | -| `Input` | 单行文本输入,用于搜索框与行内表单。 | +| `Input` | 单行文本输入,用于搜索框与行内表单。ref 指向原生输入框,供焦点控制使用,并在卸载时清空。 | | `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 | | `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 | | `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。 | diff --git a/packages/client/ui-primitives/src/Input.tsx b/packages/client/ui-primitives/src/Input.tsx index 3af60f9d18..888bc11afb 100644 --- a/packages/client/ui-primitives/src/Input.tsx +++ b/packages/client/ui-primitives/src/Input.tsx @@ -1,23 +1,24 @@ // Input: single-line text input atom (search boxes, inline forms). Composer // textareas are NOT this atom — they live with the conversation package. -import type { InputHTMLAttributes, ReactNode } from 'react' +import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react' import clsx from 'clsx' import css from './Input.module.css' /** * Render a text input with an optional leading icon. * @param props.icon - optional 16px leading icon node. + * @param ref - the native input, cleared when it unmounts. * @returns wrapper span containing the native input; input attributes pass through. */ -export function Input({ icon, className, ...rest }: { +export const Input = forwardRef) { +} & InputHTMLAttributes>(function Input({ icon, className, ...rest }, ref) { return ( {icon != null && {icon}} - + ) -} +}) diff --git a/packages/client/ui-primitives/tests/atoms.client.spec.tsx b/packages/client/ui-primitives/tests/atoms.client.spec.tsx index 842ea536cf..bc48ea0d38 100644 --- a/packages/client/ui-primitives/tests/atoms.client.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.client.spec.tsx @@ -59,6 +59,16 @@ describe('Pill', () => { }) describe('Input', () => { + it('exposes its native input for focus and releases the ref on unmount', () => { + const ref = createRef() + const { unmount } = render() + expect(ref.current).toBe(screen.getByRole('textbox', { name: 'Search' })) + ref.current?.focus() + expect(document.activeElement).toBe(ref.current) + unmount() + expect(ref.current).toBeNull() + }) + it('forwards value/onChange and renders the leading icon', () => { const onChange = vi.fn() render(} value="q" onChange={onChange} placeholder="search" />) From 989b93d86ff6b796bb8355bbbd741439343c955e Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 28 Sep 2026 15:33:28 +0800 Subject: [PATCH 18/42] feat(web): unify grouped model picker ordering and sticky headers --- apps/web/tests/default-model.e2e.ts | 53 ++++- .../default-model/command-picker.expected.md | 16 +- packages/client/ui-commands/README.i18n.yaml | 8 +- packages/client/ui-commands/README.md | 6 +- packages/client/ui-commands/README.zh.md | 6 +- .../src/client/PopupSelectView.tsx | 65 +++--- .../client/ui-commands/src/client/contract.ts | 13 ++ .../client/ui-commands/src/client/index.ts | 2 +- .../ui-commands/src/client/option-groups.ts | 24 ++ .../client/ui-commands/src/client/popup.ts | 47 ++-- .../tests/popup-view.client.spec.tsx | 24 ++ .../ui-commands/tests/popup.client.spec.ts | 30 +++ .../ui-model-selection/README.i18n.yaml | 8 +- packages/client/ui-model-selection/README.md | 6 +- .../client/ui-model-selection/README.zh.md | 6 +- .../src/client/ModelSelect.module.css | 24 -- .../src/client/ModelSelect.tsx | 38 +-- .../ui-model-selection/src/client/index.ts | 6 +- .../src/client/provider-order.ts | 12 + .../tests/browser-plugin.client.spec.ts | 29 ++- .../client/ui-primitives/README.i18n.yaml | 8 +- packages/client/ui-primitives/README.md | 5 + packages/client/ui-primitives/README.zh.md | 5 + .../ui-primitives/src/MenuGroup.module.css | 23 ++ .../client/ui-primitives/src/MenuGroup.tsx | 56 +++++ packages/client/ui-primitives/src/index.ts | 1 + .../tests/menu-group.client.spec.tsx | 219 ++++++++++++++++++ 27 files changed, 610 insertions(+), 130 deletions(-) create mode 100644 packages/client/ui-commands/src/client/option-groups.ts create mode 100644 packages/client/ui-model-selection/src/client/provider-order.ts create mode 100644 packages/client/ui-primitives/src/MenuGroup.module.css create mode 100644 packages/client/ui-primitives/src/MenuGroup.tsx create mode 100644 packages/client/ui-primitives/tests/menu-group.client.spec.tsx diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index 53fbb421c5..d87bf4ce4d 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -13,7 +13,7 @@ import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' -import type { Browser, Page } from 'playwright' +import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { SessionId } from '@deepseek-ai/dsh-session' @@ -135,6 +135,57 @@ describe('web e2e: the composer model switch is the default for later sessions', } }) + it('shares provider order, fuzzy result order and sticky header material between both model pickers', async () => { + const readGroups = (surface: Locator, role: 'option' | 'menuitemradio') => surface.locator('[data-menu-group]') + .evaluateAll((groups, rowRole) => groups.map(group => ({ + label: group.querySelector('[data-menu-group-heading]')!.textContent, + rows: [...group.querySelectorAll(`[role="${rowRole}"]`)].map(row => row.textContent), + })), role) + const checkSticky = async (surface: Locator, viewport: Locator) => { + // Bound only this test's list viewport so native scrolling can cross a heading. + await viewport.evaluate((node) => { node.style.maxHeight = '80px'; node.scrollTop = 0 }) + const heading = surface.locator('[data-menu-group-heading]').first() + await expect.poll(() => heading.getAttribute('data-stuck')).toBeNull() + const clear = await heading.evaluate(node => getComputedStyle(node).backgroundColor) + expect(clear).toBe('rgba(0, 0, 0, 0)') + await viewport.evaluate((node) => { node.scrollTop = 10 }) + await expect.poll(() => heading.getAttribute('data-stuck')).toBe('') + const stuck = await heading.evaluate((node) => { + const style = getComputedStyle(node) + return { fill: style.backgroundColor, radius: style.borderRadius, font: style.fontSize, padding: style.padding } + }) + await viewport.evaluate((node) => { node.scrollTop = 0 }) + await expect.poll(() => heading.getAttribute('data-stuck')).toBeNull() + return stuck + } + try { + await page.getByRole('button', { name: /^选择模型/ }).click() + await page.getByRole('menuitem', { name: /^模型/ }).click() + const menu = page.getByRole('group', { name: '模型与推理等级', exact: true }) + const menuSearch = page.getByRole('searchbox', { name: '搜索模型…' }) + const order = await readGroups(menu, 'menuitemradio') + const sticky = await checkSticky(menu, menu.getByRole('menu', { name: '模型', exact: true })) + await menuSearch.fill(' ACMLG ') + const filtered = await readGroups(menu, 'menuitemradio') + await menuSearch.press('Escape') + await page.keyboard.press('Escape') + await page.getByRole('button', { name: '添加文件或调用指令', exact: true }).click() + await page.getByRole('option', { name: /^模型/ }).click() + const popup = page.locator('[aria-label="/model 选项"]') + await popup.getByRole('option').first().waitFor() + expect(await readGroups(popup, 'option')).toEqual(order) + expect(await checkSticky(popup, popup.getByRole('listbox'))).toEqual(sticky) + const popupSearch = page.getByRole('textbox', { name: '筛选选项', exact: true }) + await popupSearch.fill(' ACMLG ') + expect(await readGroups(popup, 'option')).toEqual(filtered) + expect(await popupSearch.evaluate(node => node === node.ownerDocument.activeElement)).toBe(true) + } finally { + await page.keyboard.press('Escape') + await page.keyboard.press('Escape') + await page.locator('[data-composer-input]').first().fill('') + } + }) + it('hides search only in the button menu when four models remain', async () => { const trigger = page.getByRole('button', { name: /^选择模型/ }) const search = page.getByRole('searchbox', { name: '搜索模型…' }) diff --git a/apps/web/tests/expected/default-model/command-picker.expected.md b/apps/web/tests/expected/default-model/command-picker.expected.md index 5a37bd75f7..22cfbf980f 100644 --- a/apps/web/tests/expected/default-model/command-picker.expected.md +++ b/apps/web/tests/expected/default-model/command-picker.expected.md @@ -1,8 +1,14 @@ - textbox "筛选选项": - /placeholder: 搜索模型… - listbox "/model 匹配项": - - option "DeepSeek-V4-Flash DeepSeek" - - option "DeepSeek-V4-Flash-Vision-Exp DeepSeek" - - option "Origin Large Origin Gateway" - - option "Acme Large Acme Gateway" [selected] - - option "Acme Small Acme Gateway" + - group "DeepSeek": + - text: DeepSeek + - option "DeepSeek-V4-Flash" + - option "DeepSeek-V4-Flash-Vision-Exp" + - group "Origin Gateway": + - text: Origin Gateway + - option "Origin Large" + - group "Acme Gateway": + - text: Acme Gateway + - option "Acme Large" [selected] + - option "Acme Small" diff --git a/packages/client/ui-commands/README.i18n.yaml b/packages/client/ui-commands/README.i18n.yaml index 5ea56f3c7f..0b344dbeac 100644 --- a/packages/client/ui-commands/README.i18n.yaml +++ b/packages/client/ui-commands/README.i18n.yaml @@ -18,8 +18,8 @@ en: 01adce72791579fa zh: 70eb55a1529038e7 /deepseek-ai-dsh-client-ui-commands/use-this-package/kinds-and-decorations: - en: 986777b40354f8e5 - zh: 405c5581db8d4c56 + en: 57679a20574d8b3f + zh: 98d5e5a7781e6de1 /deepseek-ai-dsh-client-ui-commands/use-this-package/built-in-row-faces: en: e96156be95ea87e3 zh: f5350db26483ec9f @@ -27,8 +27,8 @@ en: 4feecdbec0c3f39e zh: 618944ecade8a3a2 /deepseek-ai-dsh-client-ui-commands/understand-the-implementation: - en: 01fb7e3ac511fecd - zh: b8b29c21c85f7a9c + en: 9fffb036b0346871 + zh: 1e0364e9b4f5a24b /deepseek-ai-dsh-client-ui-commands/further-exploration: en: 409c04f534d23de7 zh: 165022d7a0a554eb diff --git a/packages/client/ui-commands/README.md b/packages/client/ui-commands/README.md index 43693b4623..0cba9477f4 100644 --- a/packages/client/ui-commands/README.md +++ b/packages/client/ui-commands/README.md @@ -33,6 +33,10 @@ A contribution is a client-owned command; a host-name collision fails loudly. It A popupSelect spec may supply `searchLabels()` to resolve its placeholder, empty-catalog text, and no-match text for each opening. Omitted labels retain the shell's generic copy; labels from one popup never carry into the next. +Options may carry `group: { name, label }`: `name` identifies the group and `label` supplies its localized sticky heading. Equal names share one group; groups retain first-occurrence order in the loaded options, including when a query hides earlier rows. Ungrouped options form one headingless block at their first occurrence. Groups without matching rows are hidden. + +The optional `searchMode` defaults to `'substring'`, matching `label` and `detail` case-insensitively without ranking. `'fuzzy-label'` matches only labels as ordered, case-insensitive subsequences and ranks within each group: prefixes first, then alignment score, then original row order. Both modes ignore leading and trailing query spaces; a blank query keeps every row in group order. Rendering, keyboard navigation, and selection use the same filtered order. `/model` opts into `'fuzzy-label'`; other commands retain substring matching unless they opt in. + ### Built-in row faces First-party command definitions carry stable `definitionId` values. The client selects their localized titles, descriptions, icons, and input spellings by identity; changing a Host description cannot change that selection. Same-name overrides without the matching identity keep their own copy and receive no first-party aliases. Chinese and English spellings resolve through the same effective Session catalog in every locale, preserving the typed spelling in the draft and submitting the registered Host name. Contributions supply their own `label`, `description`, and `icon`, read on every candidate pass. Empty-query section order follows names, with unlisted rows closing Commands. @@ -46,7 +50,7 @@ When the composer submits with images or generic files, only a host command decl ## Understand the implementation -Popup selection panels fill the composer overlay's width, matching the slash menu. Popup search fields keep transparent backgrounds and borders in both palettes. Menus use the shared `MenuSurface` material, including the macOS backing for background blur; custom content follows the [menu rules](../../../docs/web-styling.md#component-rules). +Popup selection panels fill the composer overlay's width, matching the slash menu. Popup search fields keep transparent backgrounds and borders in both palettes. Grouped options use [ui-primitives](../ui-primitives/README.md#understand-the-implementation)' shared `MenuGroup` headings and `observeStickyMenuGroups`; the view cleans up and rebuilds the observer when rendered groups change. Menus use the shared `MenuSurface` material, including the macOS backing for background blur; custom content follows the [menu rules](../../../docs/web-styling.md#component-rules).
Implementation internals — click to expand diff --git a/packages/client/ui-commands/README.zh.md b/packages/client/ui-commands/README.zh.md index 204778a10e..bfa03d9093 100644 --- a/packages/client/ui-commands/README.zh.md +++ b/packages/client/ui-commands/README.zh.md @@ -33,6 +33,10 @@ kind: "package-reference" popupSelect 规格可提供 `searchLabels()`,在每次打开时解析占位文字、空目录提示和无匹配提示。未提供时保留外壳的通用文案;一个弹窗的文案不会沿用到下一个弹窗。 +选项可携带 `group: { name, label }`:`name` 标识分组,`label` 提供本地化吸顶标题。同名选项归入同一组;分组保持在已加载选项中首次出现的顺序,即使查询隐藏了较早的行也不变。未分组选项在首次出现的位置组成一个无标题区块。没有匹配行的分组会隐藏。 + +可选的 `searchMode` 默认为 `'substring'`,不区分大小写地匹配 `label` 与 `detail` 的子串,不进行相关性排序。`'fuzzy-label'` 仅匹配标签中按顺序、不区分大小写的子序列,并在组内排序:前缀优先,其次按匹配得分,最后按原始行顺序。两种模式均忽略查询首尾空格;空查询按分组顺序保留所有行。渲染、键盘导航与选择使用相同的筛选顺序。`/model` 启用 `'fuzzy-label'`;其他命令未显式启用时仍使用子串匹配。 + ### 内置行的展示面 内置命令定义携带稳定的 `definitionId`。客户端按标识选择本地化标题、说明、图标和输入写法,修改宿主说明不会改变选择结果。没有匹配标识的同名覆盖保留自己的文案,也不获得内置别名。在任何界面语言下,中英文写法都通过同一个会话有效目录解析,草稿保留手输写法,提交使用宿主注册名。贡献项提供自己的 `label`、`description` 和 `icon`,每次生成候选项时读取。空查询按名称确定小节顺序,未列出的行排在「指令」末尾。 @@ -46,7 +50,7 @@ composer 携带图片或通用文件提交时,只有声明了 `input.attachmen ## 理解实现 -弹窗选择面板撑满 composer 浮层宽度,与斜杠菜单一致。弹窗搜索框在浅/深色主题下均保持背景和边框透明。菜单采用共享 `MenuSurface` 材质,包括用于背景模糊的 macOS 底层;自定义内容遵循[菜单规则](../../../docs/web-styling.zh.md#component-rules)。 +弹窗选择面板撑满 composer 浮层宽度,与斜杠菜单一致。弹窗搜索框在浅/深色主题下均保持背景和边框透明。分组选项使用 [ui-primitives](../ui-primitives/README.zh.md#understand-the-implementation) 共享的 `MenuGroup` 标题与 `observeStickyMenuGroups`;渲染分组变化时,视图清理并重建观察器。菜单采用共享 `MenuSurface` 材质,包括用于背景模糊的 macOS 底层;自定义内容遵循[菜单规则](../../../docs/web-styling.zh.md#component-rules)。
实现细节——点击展开 diff --git a/packages/client/ui-commands/src/client/PopupSelectView.tsx b/packages/client/ui-commands/src/client/PopupSelectView.tsx index efa55b554c..355b913502 100644 --- a/packages/client/ui-commands/src/client/PopupSelectView.tsx +++ b/packages/client/ui-commands/src/client/PopupSelectView.tsx @@ -10,13 +10,15 @@ * null; the overlay slot stays mounted. The card height clamps to the space * above the composer. */ -import { MenuSurface } from '@deepseek-ai/dsh-client-ui-primitives' -import { useEffect, useRef } from 'react' +import { MenuGroup, MenuSurface, observeStickyMenuGroups } from '@deepseek-ai/dsh-client-ui-primitives' +import { Fragment, useEffect, useLayoutEffect, useMemo, useRef } from 'react' import { useSyncExternalStore } from 'react' import clsx from 'clsx' import { IconCheckOutlineRegular, RiskConfirmation, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import { filterOptions } from './popup.ts' +import type { SelectOption } from './contract.ts' +import { groupOptions } from './option-groups.ts' import type { PopupSelectController } from './popup.ts' import css from './PopupSelectView.module.css' @@ -44,6 +46,15 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) { ) const cardRef = useRef(null) const searchRef = useRef(null) + const viewportRef = useRef(null) + const rows = useMemo(() => filterOptions(state.options, state.search, state.searchMode), + [state.options, state.search, state.searchMode]) + const groups = useMemo(() => groupOptions(rows), [rows]) + useLayoutEffect(() => { + const viewport = viewportRef.current + if (viewport === null) return + return observeStickyMenuGroups(viewport) + }, [state.open, state.status, state.confirming, groups]) // The card is bottom-anchored above the composer; clamp the design cap to // the space above it, re-measured on every store update. const maxHeight = useAnchoredMaxHeight(cardRef, MAX_HEIGHT, state) @@ -54,7 +65,7 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) { useEffect(() => { if (active === null) return cardRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' }) - }, [active]) + }, [active, rows, state.confirming]) // Focus ownership: the search input grabs on open, and ANY outside // pointer interaction dismisses — @@ -78,7 +89,6 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) { if (!state.open) return null - const rows = filterOptions(state.options, state.search) const confirmation = state.confirming?.confirmation const emptyLabel = state.searchLabels === null ? t('status.empty') @@ -121,6 +131,25 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) { } } + const renderOption = (option: SelectOption, index: number) => ( +
{ void popup.select(index) }} + onMouseEnter={() => { popup.highlight(index) }} + > + + {option.label} + {option.badge !== undefined && {option.badge}} + + {option.detail !== undefined && {option.detail}} + {option.active === true && } +
+ ) + let optionIndex = 0 return ( <> {state.confirming === null && ( @@ -153,28 +182,12 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) { {state.submitting &&
{t('status.applying')}
} {state.status === 'ready' && rows.length === 0 &&
{emptyLabel}
} {state.status === 'ready' && ( -
- {rows.map((option, index) => ( -
{ void popup.select(index) }} - onMouseEnter={() => { popup.highlight(index) }} - > - - {option.label} - {option.badge !== undefined && {option.badge}} - - {option.detail !== undefined && {option.detail}} - {option.active === true && } -
- ))} +
+ {groups.map(({ group, rows: groupRows }) => group === undefined + ? {groupRows.map(option => renderOption(option, optionIndex++))} + : + {groupRows.map(option => renderOption(option, optionIndex++))} + )}
)} diff --git a/packages/client/ui-commands/src/client/contract.ts b/packages/client/ui-commands/src/client/contract.ts index 6b30e265ab..5cca3ee174 100644 --- a/packages/client/ui-commands/src/client/contract.ts +++ b/packages/client/ui-commands/src/client/contract.ts @@ -17,6 +17,15 @@ export interface SelectConfirmation { readonly confirmLabel: string } +/** Caller-owned group name and localized heading for popup options. */ +export interface SelectOptionGroup { + readonly name: string + readonly label: string +} + +/** Search behavior selected by the popup owner. */ +export type PopupSearchMode = 'substring' | 'fuzzy-label' + /** One option row of a popupSelect shell. */ export interface SelectOption { readonly id: string @@ -24,6 +33,8 @@ export interface SelectOption { /** Optional short marker rendered as a superscript beside the label. */ readonly badge?: string readonly detail?: string + /** Equal group names share a sticky heading; groups retain first-occurrence order. */ + readonly group?: SelectOptionGroup /** * The row the shell's highlight parks on when the panel opens, so an accept * gesture made without looking confirms the value in use. A business package @@ -52,6 +63,8 @@ export interface PopupSearchLabels { */ export interface PopupSelectSpec { readonly kind: 'popupSelect' + /** Defaults to substring search; fuzzy-label ranks names within each group. */ + readonly searchMode?: PopupSearchMode /** * Supply localized search copy for this opening; omitted uses the shell's generic copy. * @returns search placeholder and empty-state labels. diff --git a/packages/client/ui-commands/src/client/index.ts b/packages/client/ui-commands/src/client/index.ts index 9a93e23cd0..a629bd3240 100644 --- a/packages/client/ui-commands/src/client/index.ts +++ b/packages/client/ui-commands/src/client/index.ts @@ -28,7 +28,7 @@ export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './pop export type { PopupSelectInjected, PopupSelectViewProps } from './PopupSelectView.tsx' export type { ActionSpec, CommandContribution, CommandDecoration, CommandUiContract, CommandUiSpec, PopupSearchLabels, PopupSelectSpec, - SelectConfirmation, SelectOption, + SelectConfirmation, SelectOption, SelectOptionGroup, PopupSearchMode, } from './contract.ts' export type { CommandKey } from './locales.ts' diff --git a/packages/client/ui-commands/src/client/option-groups.ts b/packages/client/ui-commands/src/client/option-groups.ts new file mode 100644 index 0000000000..ac2ff883e6 --- /dev/null +++ b/packages/client/ui-commands/src/client/option-groups.ts @@ -0,0 +1,24 @@ +/** Stable grouping shared by popup filtering and rendering. */ +import type { SelectOption, SelectOptionGroup } from './contract.ts' + +interface OptionGroup { + readonly group: SelectOptionGroup | undefined + readonly rows: readonly SelectOption[] +} + +/** + * Group rows by their caller-owned group name, without sorting groups or rows. + * Ungrouped rows occupy one block at their first occurrence. + * @param options - Options in display order. + * @returns groups in first-occurrence order, with original option objects. + */ +export function groupOptions(options: readonly SelectOption[]): OptionGroup[] { + const groups = new Map() + for (const option of options) { + const name = option.group?.name + const existing = groups.get(name) + if (existing !== undefined) existing.rows.push(option) + else groups.set(name, { group: option.group, rows: [option] }) + } + return [...groups.values()] +} diff --git a/packages/client/ui-commands/src/client/popup.ts b/packages/client/ui-commands/src/client/popup.ts index a6b7bb4c36..1446cc49d6 100644 --- a/packages/client/ui-commands/src/client/popup.ts +++ b/packages/client/ui-commands/src/client/popup.ts @@ -12,7 +12,9 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { TokenSpan } from '@deepseek-ai/dsh-client-ui-input-trigger/client' -import type { PopupSearchLabels, PopupSelectSpec, SelectOption } from './contract.ts' +import type { PopupSearchLabels, PopupSearchMode, PopupSelectSpec, SelectOption } from './contract.ts' +import { rankByName } from '@deepseek-ai/dsh-client-ui-primitives' +import { groupOptions } from './option-groups.ts' /** * The command token segment snapshotted at shell-open time, replayed to the @@ -31,7 +33,7 @@ export type TokenSegment = * session wiring passes its session projection; the controller only carries * it from open() to the callbacks). */ -export interface PopupSpec extends Pick { +export interface PopupSpec extends Pick { /** Load the option rows once per open (retry after failure reuses the same signal). */ options(context: TCtx, signal: AbortSignal): Promise /** Settle the picked option against the open-time context. */ @@ -65,6 +67,8 @@ export interface PopupState { readonly search: string /** Command-owned copy for this opening; null uses generic shell labels. */ readonly searchLabels: PopupSearchLabels | null + /** Search policy resolved from the command spec at opening. */ + readonly searchMode: PopupSearchMode /** * Highlight index into the filtered row list: 0 until options land; afterwards * the row the loaded list marks as the current value @@ -83,21 +87,31 @@ export interface PopupState { } const CLOSED: PopupState = { - open: false, command: null, status: 'pending', options: [], search: '', searchLabels: null, active: 0, + open: false, command: null, status: 'pending', options: [], search: '', searchLabels: null, searchMode: 'substring', active: 0, submitting: false, confirming: null, acknowledged: false, error: null, } /** - * Filter option rows against the shell's local search text (case-insensitive - * substring over label and detail; blank search keeps every row). + * Filter rows using substring matching, or rank labels fuzzily within each group. + * Blank search keeps every row; fuzzy matching preserves group order. * @param options - the loaded rows. * @param search - the shell's search text. - * @returns the rows the shell shows and highlights over. + * @param mode - the command's policy; defaults to substring over label and detail. + * @returns the original option objects in the order shown and used for selection. */ -export function filterOptions(options: readonly SelectOption[], search: string): readonly SelectOption[] { +export function filterOptions( + options: readonly SelectOption[], search: string, mode: PopupSearchMode = 'substring', +): readonly SelectOption[] { const query = search.trim().toLowerCase() - if (query === '') return options - return options.filter(o => o.label.toLowerCase().includes(query) || (o.detail?.toLowerCase().includes(query) ?? false)) + const groups = groupOptions(options) + const ordered = groups.some(group => group.group !== undefined) ? groups.flatMap(group => group.rows) : options + if (query === '') return ordered + if (mode === 'fuzzy-label') { + return groups.flatMap(group => rankByName( + group.rows.map(option => ({ name: option.label, option })), query, + ).map(row => row.option)) + } + return ordered.filter(o => o.label.toLowerCase().includes(query) || (o.detail?.toLowerCase().includes(query) ?? false)) } /** @@ -107,10 +121,11 @@ export function filterOptions(options: readonly SelectOption[], search: string): * made without looking confirms that value instead of the topmost row. * @param options - the loaded rows. * @param search - the shell's live filter text (non-empty after a retry). + * @param mode - the command's search policy. * @returns index into the filtered rows. */ -function currentIndex(options: readonly SelectOption[], search: string): number { - const at = filterOptions(options, search).findIndex(option => option.active === true) +function currentIndex(options: readonly SelectOption[], search: string, mode: PopupSearchMode): number { + const at = filterOptions(options, search, mode).findIndex(option => option.active === true) return at === -1 ? 0 : at } @@ -158,7 +173,7 @@ export class PopupSelectController { this.binding?.abort.abort() const binding: OpenBinding = { command, spec, context, segment, abort: new AbortController() } this.binding = binding - this.state.set({ ...CLOSED, open: true, command, searchLabels }) + this.state.set({ ...CLOSED, open: true, command, searchLabels, searchMode: spec.searchMode ?? 'substring' }) this.load(binding) } @@ -168,7 +183,7 @@ export class PopupSelectController { (options) => { if (this.binding !== binding) return const current = this.state.getSnapshot() - this.state.set({ ...current, status: 'ready', options, active: currentIndex(options, current.search), error: null }) + this.state.set({ ...current, status: 'ready', options, active: currentIndex(options, current.search, current.searchMode), error: null }) }, (error: unknown) => { if (this.binding !== binding) return @@ -207,7 +222,7 @@ export class PopupSelectController { move(dir: 1 | -1): void { const s = this.state.getSnapshot() if (!s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return - const rows = filterOptions(s.options, s.search) + const rows = filterOptions(s.options, s.search, s.searchMode) if (rows.length === 0) return const active = (s.active + dir + rows.length) % rows.length this.state.set({ ...s, active }) @@ -221,7 +236,7 @@ export class PopupSelectController { highlight(index: number): void { const s = this.state.getSnapshot() if (!s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return - if (index < 0 || index >= filterOptions(s.options, s.search).length || index === s.active) return + if (index < 0 || index >= filterOptions(s.options, s.search, s.searchMode).length || index === s.active) return this.state.set({ ...s, active: index }) } @@ -239,7 +254,7 @@ export class PopupSelectController { const binding = this.binding const s = this.state.getSnapshot() if (binding === null || !s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return - const option = filterOptions(s.options, s.search)[index] + const option = filterOptions(s.options, s.search, s.searchMode)[index] if (option === undefined) return if (option.confirmation !== undefined) { this.state.set({ ...s, confirming: option, acknowledged: false, error: null }) diff --git a/packages/client/ui-commands/tests/popup-view.client.spec.tsx b/packages/client/ui-commands/tests/popup-view.client.spec.tsx index cf6fcdc044..3003a868d9 100644 --- a/packages/client/ui-commands/tests/popup-view.client.spec.tsx +++ b/packages/client/ui-commands/tests/popup-view.client.spec.tsx @@ -78,6 +78,30 @@ function rowLabels(): string[] { } describe('PopupSelectView', () => { + it('renders provider groups and selects the same fuzzy-ranked row the user sees', async () => { + const alpha = { name: 'alpha', label: 'Alpha provider' } + const beta = { name: 'beta', label: 'Beta provider' } + const options: SelectOption[] = [ + { id: 'a-deep', label: 'DeepSeek Flash', group: alpha }, + { id: 'b-flash', label: 'Flash Beta', group: beta }, + { id: 'a-prefix', label: 'Flash Lite', group: alpha, active: true }, + { id: 'other', label: 'Unmatched', group: { name: 'other', label: 'Other provider' } }, + ] + const onSelect = vi.fn() + const { search } = await mountOpen({ searchMode: 'fuzzy-label', options: () => Promise.resolve(options), onSelect }) + expect(screen.getAllByRole('group').map(group => group.querySelector('[data-menu-group-heading]')?.textContent)) + .toEqual(['Alpha provider', 'Beta provider', 'Other provider']) + expect(rowLabels()).toEqual(['DeepSeek Flash', 'Flash Lite', 'Flash Beta', 'Unmatched']) + fireEvent.change(search, { target: { value: 'flash' } }) + expect(rowLabels()).toEqual(['Flash Lite', 'DeepSeek Flash', 'Flash Beta']) + expect(screen.queryByRole('group', { name: 'Other provider' })).toBeNull() + expect(document.activeElement).toBe(search) + fireEvent.keyDown(search, { key: 'ArrowUp' }) + expect(screen.getByRole('option', { name: 'Flash Beta' }).getAttribute('aria-selected')).toBe('true') + await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }); await Promise.resolve() }) + expect(onSelect).toHaveBeenCalledWith(options[1], 'ctx-A') + }) + it('uses command-owned search copy and resets to generic copy for the next popup', async () => { const labels = { placeholder: '搜索模型…', empty: '没有可用的模型。', noResults: '没有匹配的模型。' } const searchLabels = vi.fn(() => labels) diff --git a/packages/client/ui-commands/tests/popup.client.spec.ts b/packages/client/ui-commands/tests/popup.client.spec.ts index 90aa599764..5561496639 100644 --- a/packages/client/ui-commands/tests/popup.client.spec.ts +++ b/packages/client/ui-commands/tests/popup.client.spec.ts @@ -48,6 +48,36 @@ async function readyPopup(overrides: Partial> = {}, deps = makeDe } describe('filterOptions', () => { + const alpha = { name: 'alpha', label: 'Alpha provider' } + const beta = { name: 'beta', label: 'Beta provider' } + const grouped: SelectOption[] = [ + { id: 'a-deep', label: 'DeepSeek Flash', group: alpha }, + { id: 'b-flash', label: 'Flash Beta', group: beta }, + { id: 'a-prefix', label: 'Flash Lite', group: alpha, active: true }, + ] + + it('keeps provider order while ranking fuzzy label matches within each group', () => { + expect(filterOptions(grouped, '')).toEqual([grouped[0], grouped[2], grouped[1]]) + expect(filterOptions(grouped, ' FLASH ', 'fuzzy-label')).toEqual([grouped[2], grouped[0], grouped[1]]) + expect(filterOptions(grouped, 'fslt', 'fuzzy-label')).toEqual([grouped[2]]) + expect(filterOptions(grouped, 'Alpha provider', 'fuzzy-label')).toEqual([]) + }) + + it('uses that same displayed order for highlighting and selection, then resets the mode on reopen', async () => { + const onSelect = vi.fn() + const { popup } = await readyPopup({ searchMode: 'fuzzy-label', options: () => Promise.resolve(grouped), onSelect }) + expect(popup.state.getSnapshot().active).toBe(1) + popup.setSearch('flash') + expect(popup.state.getSnapshot().active).toBe(0) + popup.move(-1) + expect(popup.state.getSnapshot().active).toBe(2) + await popup.select(2) + expect(onSelect).toHaveBeenCalledWith(grouped[1], CTX_A) + popup.open('theme', spec(), CTX_A, SEGMENT) + await Promise.resolve() + expect(popup.state.getSnapshot().searchMode).toBe('substring') + }) + it('matches case-insensitively over label and detail; blank keeps all', () => { expect(filterOptions(OPTIONS, '')).toBe(OPTIONS) expect(filterOptions(OPTIONS, ' ')).toBe(OPTIONS) diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index 988e9e48ff..804d66694f 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/README.i18n.yaml @@ -18,8 +18,8 @@ en: 62235f8c8c9e1e7e zh: c18dc9c38ef9d4f9 /deepseek-ai-dsh-client-ui-model-selection/use-this-package/model-and-effort: - en: 1abd83031efe3051 - zh: ac85bd0ff44a9505 + en: 5e2ec95807548cb4 + zh: b47754c9cdf2d068 /deepseek-ai-dsh-client-ui-model-selection/use-this-package/unroutable-sessions: en: 7f20428c747ff052 zh: 975e6de728e02fa0 @@ -27,8 +27,8 @@ en: 261bbf42296243af zh: 72779fb7b8fbc473 /deepseek-ai-dsh-client-ui-model-selection/understand-the-implementation: - en: 493088e8de6be91c - zh: 9ecee4605b4dbad4 + en: f3d38594439eab3d + zh: f255974e61696dca /deepseek-ai-dsh-client-ui-model-selection/further-exploration: en: 14acd4692e923480 zh: ff89aed852aa0da8 diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index d795762ba1..77080c3a4d 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -37,11 +37,11 @@ Mouse selection uses native browser clicks, including their cancellation behavio ### Model and effort -The button's model menu shows search only when its full catalog contains more than four models; smaller catalogs focus the current model or first row and support keyboard navigation directly. The `/model` command always keeps its search field. When shown, the button menu's search field matches model names case-insensitively, including nonconsecutive characters in order; leading and trailing spaces are ignored. Within each provider, prefix matches come first, followed by alignment score and then catalog order. Empty groups are hidden. Empty catalogs and queries without matches are announced through a status region. Search keeps focus while arrow keys cycle the highlighted result across provider groups; Enter or Tab selects it. Left and right arrows keep their native caret behavior. Opening highlights the current model or the first available row, and editing the query resets the highlight to the first result. Reopening the model pane clears the query. +The button's model menu shows search only when its full catalog contains more than four models; smaller catalogs focus the current model or first row and support keyboard navigation directly. The `/model` command always keeps its search field. Both search fields match model names case-insensitively, including nonconsecutive characters in order; leading and trailing spaces are ignored. Within each provider, prefix matches come first, followed by alignment score and then catalog order. Empty groups are hidden. Empty catalogs and queries without matches are announced through a status region. Search keeps focus while arrow keys cycle the highlighted result across provider groups; Enter or Tab selects it. Left and right arrows keep their native caret behavior. Opening highlights the current model or the first available row, and editing the query resets the highlight to the first result. Reopening the model pane clears the query. Model and reasoning-effort names use weight 400 (regular) in the composer menu, including the selected item. The search field follows the command popup's compact treatment with transparent background and border in both palettes, with no leading icon and a caption-tone placeholder. The clear button appears for a nonempty query and restores the full list with focus in the search field. -Models stay grouped by provider. Provider headings stay transparent until scrolling pins them at the top of the list; only then do they use the theme's 94%-opaque group-header fill. Scrolling back restores transparency. This applies in both palettes and on every platform, while the menu retains its translucent material. Outside macOS Desktop, provider headings share the option rows' corner radius. The composer menu shows model and effort names only, with DeepSeek Account first and DeepSeek second; third-party providers retain their catalog order. Navigation chevrons use `--dsw-alias-menu-icon`. The `/model` popup shows model and provider names without catalog descriptions. Its search placeholder, no-match text, and empty-catalog text use the same localized labels as the composer model menu. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. +Both entries group models by provider, with DeepSeek Account first and DeepSeek second; third-party providers retain their catalog order. Both use the shared sticky-heading presentation from [ui-primitives](../ui-primitives/README.md#understand-the-implementation): transparent at rest, with the theme's 94%-opaque fill only while pinned, and `md` corners outside macOS Desktop. The composer menu shows model and effort names only. Navigation chevrons use `--dsw-alias-menu-icon`. The `/model` popup shows provider names as group headings and model names as rows, without repeating the provider on each row or showing catalog descriptions. Its search placeholder, no-match text, and empty-catalog text use the same localized labels as the composer model menu. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. The composer replaces the model and effort text with the Models icon when the expanded controls cannot share one line, and restores the text when space permits. The full selection remains available in the trigger's accessible name, tooltip, and menu. @@ -63,6 +63,8 @@ Menus use the shared `MenuSurface` material, including the macOS backing for bac
Implementation internals — click to expand +The composer `ModelSelect` and `/model` option builder share [provider ordering](src/client/provider-order.ts), while both searches use `rankByName` within each provider. The command supplies optional groups and `searchMode: 'fuzzy-label'` through the [popupSelect API](../ui-commands/README.md#use-this-package); both entries use `MenuGroup` and rebuild its sticky observer when the rendered groups change. The command popup fills the composer overlay, while the button retains its compact menu. + Two entries over ONE per-session directory owned by `ModelDirectoryResolver` (`ctx.modelDirectories`): the `/model` popupSelect contribution (registered through `ctx.commandUi`) and the composer's named `conversation.input.model` seat both load the session's available directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance, so a switch made in either entry is what the other shows next. Directory loads and selections share a generation counter so an older response never overwrites a newer one. The directory publishes the latest submitted selection as `pending` until it settles or a connection reset invalidates it; a connection reset drops every resident projection and repulls the Host-restored selection before display. Directories are per-session, resolved lazily, and disposed with the session scope; addressed subagent sessions expose neither entry. Every resident directory refetches directly on forwarded `llm/adapters-updated`, `settings/document-updated`, and credential update events.
diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index c8c371430c..9894348717 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -37,11 +37,11 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示 ### 模型与推理强度 -按钮模型菜单仅在完整目录超过四个模型时显示搜索框;较小目录聚焦当前模型或首行,直接支持键盘导航。`/model` 命令始终保留搜索框。按钮菜单显示搜索框时,按名称进行不区分大小写的匹配,支持按顺序输入不连续字符,并忽略首尾空格。每个提供方内的结果优先按前缀匹配、其次按匹配得分、最后按目录顺序排列。没有匹配项的分组会隐藏。空目录和无匹配结果均通过状态区域通知屏幕阅读器。方向键在各提供方分组的结果间循环移动高亮,焦点保留在搜索框;Enter 或 Tab 选定高亮项。左右方向键仍用于移动输入光标。打开时高亮当前模型或首个可用选项,修改搜索内容后高亮首个结果。重新打开模型面板会清空搜索内容。 +按钮模型菜单仅在完整目录超过四个模型时显示搜索框;较小目录聚焦当前模型或首行,直接支持键盘导航。`/model` 命令始终保留搜索框。两个搜索框均按模型名称进行不区分大小写的匹配,支持按顺序输入不连续字符,并忽略首尾空格。每个提供方内的结果优先按前缀匹配、其次按匹配得分、最后按目录顺序排列。没有匹配项的分组会隐藏。空目录和无匹配结果均通过状态区域通知屏幕阅读器。方向键在各提供方分组的结果间循环移动高亮,焦点保留在搜索框;Enter 或 Tab 选定高亮项。左右方向键仍用于移动输入光标。打开时高亮当前模型或首个可用选项,修改搜索内容后高亮首个结果。重新打开模型面板会清空搜索内容。 composer 菜单中的模型名称和推理等级均使用 400(regular)字重,包括选中项。搜索框沿用 command 弹窗的紧凑样式,在浅/深色主题下均保持背景和边框透明,不显示前置图标,占位文字使用 caption 色。搜索内容非空时显示清除按钮;点击后恢复完整列表,并将焦点留在搜索框。 -模型按提供方分组。提供方标题默认透明,只有滚动使其固定在列表顶部时才使用主题提供的 94% 不透明分组标题填充;滚回原位后恢复透明。这一行为适用于浅/深色主题和所有平台,菜单仍保留半透明材质。除 macOS 桌面端外,提供方标题与选项行使用相同圆角。composer 菜单只显示模型与推理强度名称,DeepSeek 账号排第一,DeepSeek 排第二,第三方提供方保持目录原有顺序。导航箭头使用 `--dsw-alias-menu-icon` 文本色。`/model` 弹窗显示模型与提供方名称,不显示目录说明。搜索占位文字、无匹配提示和空目录提示与 composer 模型菜单共用同一组本地化文案。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 +两个入口均按提供方分组,DeepSeek 账号排第一,DeepSeek 排第二,第三方提供方保持目录原有顺序。两者采用 [ui-primitives](../ui-primitives/README.zh.md#understand-the-implementation) 的共享吸顶标题:原位透明,仅吸顶时使用主题的 94% 不透明填充,macOS 桌面端以外使用 `md` 圆角。composer 菜单只显示模型与推理强度名称。导航箭头使用 `--dsw-alias-menu-icon` 文本色。`/model` 弹窗以提供方名称作为分组标题、模型名称作为选项行,不在每行重复提供方,也不显示目录说明。搜索占位文字、无匹配提示和空目录提示与 composer 模型菜单共用同一组本地化文案。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 展开的控件无法排在同一行时,composer 将模型与推理强度文字替换为模型图标;空间足够后恢复文字。触发器的无障碍名称、提示和菜单仍提供完整选择。 @@ -63,6 +63,8 @@ composer 菜单中的模型名称和推理等级均使用 400(regular)字重
实现细节——点击展开 +composer 的 `ModelSelect` 与 `/model` 选项构建器共用[提供方排序](src/client/provider-order.ts),两个搜索框均在每个提供方内使用 `rankByName`。命令通过 [popupSelect API](../ui-commands/README.zh.md#use-this-package) 提供可选分组与 `searchMode: 'fuzzy-label'`;两个入口均使用 `MenuGroup`,并在渲染分组变化时重建其吸顶观察器。命令弹窗撑满 composer 浮层,按钮则保留紧凑菜单。 + 两个入口共用一份由 `ModelDirectoryResolver`(`ctx.modelDirectories`)持有的会话级目录:`/model` popupSelect 贡献项(经 `ctx.commandUi` 注册)与 composer 的具名 `conversation.input.model` 位都经 `session.models` 加载会话的可用目录、经 `session.selectModel` 通过同一个 `ModelDirectory` 实例提交,因此任一入口所做的切换正是另一个入口接下来显示的。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果。目录把最近一次提交的选择发布为 `pending`,直到它完成或被连接重置作废;连接重置丢弃所有常驻投影,并在显示前重新拉取 Host 恢复的选择。目录按会话惰性解析,随会话作用域一并 dispose(资源释放);已寻址 subagent 会话不公开任一入口。每份常驻目录都会直接在转发的 `llm/adapters-updated`、`settings/document-updated` 与凭据更新事件上重拉。
diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.module.css b/packages/client/ui-model-selection/src/client/ModelSelect.module.css index 74ec5730f5..f4f42cb9cc 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.module.css +++ b/packages/client/ui-model-selection/src/client/ModelSelect.module.css @@ -218,30 +218,6 @@ overflow-y: auto; } -.group + .group { - margin-top: 3px; -} - -.groupTitle { - position: sticky; - top: 0; - z-index: 1; - padding: 4px 7px 2px; - background: transparent; - color: var(--dsw-alias-label-tertiary); - font-size: 11px; - line-height: 16px; - font-weight: 500; -} - -.groupTitle[data-stuck] { - background: var(--dsw-alias-menu-group-header-fill); -} - -:global(html:not([data-platform='darwin'])) .groupTitle { - border-radius: var(--dsw-radius-md); -} - .option { box-sizing: border-box; display: flex; diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index 2b6ba60c15..f3524df753 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -25,7 +25,7 @@ * chevron, and each row whose value that selection carries shows one in place * of its check mark. */ -import { MenuSurface } from '@deepseek-ai/dsh-client-ui-primitives' +import { MenuGroup, MenuSurface, observeStickyMenuGroups } from '@deepseek-ai/dsh-client-ui-primitives' import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type KeyboardEvent, type FocusEvent, @@ -40,6 +40,7 @@ import { import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ModelSelectInjected } from './slots.ts' import css from './ModelSelect.module.css' +import { orderModelProviders } from './provider-order.ts' /** Which pane the dropdown shows: the two-row root or one drilled-in list. */ type Pane = 'root' | 'model' | 'effort' @@ -89,9 +90,7 @@ export function ModelSelect( const itemRefs = useRef<(HTMLButtonElement | null)[]>([]) const id = useId() - const groups = useMemo(() => state.groups.toSorted((left, right) => - (left.id === 'deepseek-account' ? 0 : left.id === 'deepseek-official' ? 1 : 2) - - (right.id === 'deepseek-account' ? 0 : right.id === 'deepseek-official' ? 1 : 2)), [state.groups]) + const groups = useMemo(() => orderModelProviders(state.groups), [state.groups]) const choices = useMemo(() => groups.flatMap(group => group.models.map(model => ({ group, @@ -195,30 +194,7 @@ export function ModelSelect( useLayoutEffect(() => { const viewport = groupsRef.current if (viewport === null) return - const entries = [...viewport.querySelectorAll(':scope > section')].map(section => ({ - section, heading: section.firstElementChild as HTMLElement, - })) - // Section boxes retain their normal positions while their headings stick. - const update = (): void => { - const top = viewport.getBoundingClientRect().top - const stuck = entries.map(({ section }) => { - const bounds = section.getBoundingClientRect() - return viewport.scrollTop > 0 && bounds.top < top && bounds.bottom > top - }) - entries.forEach(({ heading }, index) => { heading.toggleAttribute('data-stuck', stuck[index]) }) - } - update() - viewport.addEventListener('scroll', update, { passive: true }) - window.addEventListener('resize', update) - const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(update) - observer?.observe(viewport) - for (const { section } of entries) observer?.observe(section) - return () => { - viewport.removeEventListener('scroll', update) - window.removeEventListener('resize', update) - observer?.disconnect() - for (const { heading } of entries) heading.removeAttribute('data-stuck') - } + return observeStickyMenuGroups(viewport) }, [available, open, pane, filteredGroups]) useLayoutEffect(() => { @@ -581,10 +557,8 @@ export function ModelSelect( hidden={filteredGroups.length === 0} > {filteredGroups.map((group) => { - const headingId = `${id}-${group.id}` return ( -
-
{group.id === 'deepseek-account' ? t('provider.account') : group.name}
+ {group.models.map((model) => { const index = modelIndex++ const selected = state.current?.provider === group.id && state.current.model === model.id @@ -621,7 +595,7 @@ export function ModelSelect( ) })} -
+ ) })}
diff --git a/packages/client/ui-model-selection/src/client/index.ts b/packages/client/ui-model-selection/src/client/index.ts index 1ae9d089ad..ca30921493 100644 --- a/packages/client/ui-model-selection/src/client/index.ts +++ b/packages/client/ui-model-selection/src/client/index.ts @@ -28,6 +28,7 @@ import { ModelDirectoryResolver } from './service.ts' import type { ModelSelectInjected } from './slots.ts' import { ModelSelect } from './ModelSelect.tsx' import { en, zh, type ModelKey } from './locales.ts' +import { orderModelProviders } from './provider-order.ts' export { ModelDirectory } from './directory.ts' export type { ModelDirectoryState } from './directory.ts' @@ -50,13 +51,13 @@ function rowId(providerId: string, modelId: string): string { /** Flatten the directory into popup rows; failure rows are listed for visibility but never selectable. */ function optionsOf(directory: ModelDirectoryState, t: TranslateNS<'model'>): SelectOption[] { const rows: SelectOption[] = [] - for (const group of directory.groups) { + for (const group of orderModelProviders(directory.groups)) { const name = group.id === 'deepseek-account' ? t('provider.account') : group.name for (const model of group.models) { rows.push({ id: rowId(group.id, model.id), label: model.name, - detail: name, + group: { name: group.id, label: name }, ...(directory.current !== null && directory.current.provider === group.id && directory.current.model === model.id @@ -133,6 +134,7 @@ export function apply(ctx: ClientContext): void { available: session => sessions.subagentAddress(session.sessionId) === undefined, ui: { kind: 'popupSelect', + searchMode: 'fuzzy-label', searchLabels: () => ({ placeholder: t('search.placeholder'), empty: t('empty.models'), diff --git a/packages/client/ui-model-selection/src/client/provider-order.ts b/packages/client/ui-model-selection/src/client/provider-order.ts new file mode 100644 index 0000000000..e17262bc00 --- /dev/null +++ b/packages/client/ui-model-selection/src/client/provider-order.ts @@ -0,0 +1,12 @@ +/** Shared provider display order for the composer and command model pickers. */ + +/** + * Put the account and official providers first, preserving every other relative order. + * @param groups - Provider groups in catalog order. + * @returns a sorted copy; model order within each group is unchanged. + */ +export function orderModelProviders(groups: readonly T[]): T[] { + return groups.toSorted((left, right) => + (left.id === 'deepseek-account' ? 0 : left.id === 'deepseek-official' ? 1 : 2) + - (right.id === 'deepseek-account' ? 0 : right.id === 'deepseek-official' ? 1 : 2)) +} diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index 8b7a21f7ed..b27163fb16 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -232,7 +232,9 @@ describe('ui-model-selection dual entry', () => { expect(options.map((o: SelectOption) => o.label)).toEqual([ 'DeepSeek-V4-Flash', 'DeepSeek-V4-Pro', 'External Flash', ]) - expect(options.map(option => option.detail)).toEqual(['DeepSeek', 'DeepSeek', 'External Provider']) + expect(options.map(option => option.group?.label)).toEqual(['DeepSeek', 'DeepSeek', 'External Provider']) + expect(options.every(option => option.detail === undefined)).toBe(true) + expect(b.popup().searchMode).toBe('fuzzy-label') expect(options[0]?.active).toBe(true) expect(options[1]?.active).toBeUndefined() expect(b.popup().searchLabels?.()).toEqual(locale === 'zh' @@ -240,6 +242,23 @@ describe('ui-model-selection dual entry', () => { : { placeholder: 'Search models…', empty: 'No models available.', noResults: 'No matching models.' }) }) + it('orders popup provider groups account-first while retaining third-party catalog order', async () => { + const b = await bench() + try { + b.setGroups([ + GROUPS[1]!, GROUPS[0]!, { ...GROUPS[0]!, id: 'deepseek-account', name: 'DeepSeek Account' }, + { ...GROUPS[1]!, id: 'last-provider', name: 'Last Provider' }, + ]) + b.remote.emit('llm/adapters-updated', []) + b.mint('s1') + const options = await b.popup().options(projection('s1'), new AbortController().signal) + expect([...new Set(options.map(option => option.group?.name))]) + .toEqual(['deepseek-account', 'deepseek-official', 'external', 'last-provider']) + } finally { + await b.ctx.fiber.dispose() + } + }) + it('a seat selection is the current the popup marks active next — one shared state', async () => { const b = await bench() b.mint('s1') @@ -279,14 +298,14 @@ describe('ui-model-selection dual entry', () => { }) }) - it.each(['en', 'zh'] as const)('localizes account provider details in the %s model popup', async (locale) => { + it.each(['en', 'zh'] as const)('localizes account provider headings in the %s model popup', async (locale) => { const b = await bench(locale) try { b.setGroups([{ ...GROUPS[0]!, id: 'deepseek-account', name: 'DeepSeek Account' }]) b.remote.emit('llm/adapters-updated', []) b.mint('s1') const options = await b.popup().options(projection('s1'), new AbortController().signal) - expect(options[0]?.detail).toContain(locale === 'zh' ? 'DeepSeek 账号' : 'DeepSeek Account') + expect(options[0]?.group?.label).toBe(locale === 'zh' ? 'DeepSeek 账号' : 'DeepSeek Account') } finally { await b.ctx.fiber.dispose() } @@ -299,14 +318,14 @@ describe('ui-model-selection dual entry', () => { b.remote.emit('llm/adapters-updated', []) b.mint('s1') const before = await b.popup().options(projection('s1'), new AbortController().signal) - expect(before.some(option => option.detail?.includes('DeepSeek Account'))).toBe(true) + expect(before.some(option => option.group?.label === 'DeepSeek Account')).toBe(true) b.setGroups(GROUPS) b.remote.emit('credentials/record-updated', ['deepseek-account-platform']) await vi.waitFor(() => { expect(b.ctx.modelDirectories.directoryFor(sid('s1')).store.getSnapshot().groups).toEqual(GROUPS) }) const after = await b.popup().options(projection('s1'), new AbortController().signal) - expect(after.some(option => option.detail?.includes('DeepSeek Account'))).toBe(false) + expect(after.some(option => option.group?.label === 'DeepSeek Account')).toBe(false) expect(after.length).toBeGreaterThan(0) } finally { await b.ctx.fiber.dispose() diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 6b157578fb..517377a20c 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -18,8 +18,8 @@ en: 7bae72c97d0449f5 zh: 4589ecc2b216edc5 /deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog: - en: c4d22d4642ec6fe0 - zh: 27c4dcae204d1f8f + en: 91ab0b7b84d060be + zh: 6d6e2882f58e71f6 /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: en: b4cd33720a6cf6b8 zh: 8db75bf633789c6d @@ -30,8 +30,8 @@ en: 4aef65dea11de083 zh: abc22cd7c65cdf38 /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation: - en: 464527753be6a4f6 - zh: d9df7442503bd58f + en: 6d228f9141c804aa + zh: 04bdfc8f3673fcd0 /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/source-map: en: 44eb32370cbeafe8 zh: 728009c66375c6bb diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index f69307078a..1e3116dc13 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -46,6 +46,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. | | `Input` | Single-line text entry for search boxes and inline forms. Its ref targets the native input for focus and is cleared on unmount. | | `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. | +| `MenuGroup`, `observeStickyMenuGroups` | Localized, accessible groups for custom menus and listboxes, with shared sticky-heading styling and scroll/resize observation; the caller owns the observer's lifetime. | | `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. | | `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. | | `Tag` | Read-only capsule badge; `tone` selects one of eight palettes. | @@ -118,6 +119,10 @@ The atoms cannot read the application locale, so every piece of user-facing copy `Menu` delegates its card material to `MenuSurface`; custom menus use the same component. `MenuSurface` forwards div props and refs, uses translucent fill and blur, and accepts `compact` for the smaller radius. Its default relative positioning contains the material layer; caller classes can supply fixed or absolute placement. On macOS, its non-interactive backing follows the card through CSS anchors and unmounts with it; the backing requires the Web shell’s isolated body. Feature classes control layout and elevation, while the component owns material and outer radius ([menu rules](../../../docs/web-styling.md#component-rules)). Modal masks retain their dark translucent fill without background blur. +`MenuGroup` renders a `role="group"` section named by its localized heading, with an instance-owned heading id. It shares heading typography, spacing, and sticky positioning between custom menus and listboxes. Headings are transparent at rest; only `data-stuck` enables the theme's 94%-opaque group-header fill in either palette. Outside `data-platform="darwin"`, headings use `--dsw-radius-md` corners; the enclosing menu keeps its translucent material. + +Call `observeStickyMenuGroups(viewport)` after rendering `MenuGroup` sections as direct children of the scroll container. It updates synchronously, then on scrolling, window resize, and viewport or section resizing when `ResizeObserver` is available. A heading is stuck only while its section straddles the viewport top and `scrollTop` is positive. Group membership is captured at setup: call the returned cleanup and observe again whenever rendered groups change, including filtering, and clean up on unmount. Cleanup removes listeners, disconnects size observation, and clears managed `data-stuck` attributes. A viewport without direct groups acquires no listeners or observer. +
Implementation internals — click to expand diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index cfc7653184..9dc080c933 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -46,6 +46,7 @@ kind: "package-library" | `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 | | `Input` | 单行文本输入,用于搜索框与行内表单。ref 指向原生输入框,供焦点控制使用,并在卸载时清空。 | | `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 | +| `MenuGroup`、`observeStickyMenuGroups` | 为自定义菜单与列表框提供本地化、可访问的分组,共用吸顶标题样式与滚动/尺寸观察;调用方负责观察器的生命周期。 | | `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 | | `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。 | | `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 | @@ -118,6 +119,10 @@ kind: "package-library" `Menu` 将卡片材质交给 `MenuSurface`,自定义菜单也使用该组件。`MenuSurface` 转发 div 属性和 ref,采用透明填充及模糊,`compact` 使用较小圆角。默认相对定位使材质层限制在容器内;调用方的类可以设置 fixed 或 absolute 定位。macOS 上,不接收交互的底层通过 CSS 锚点跟随卡片,并随卡片卸载;该底层要求 Web 外壳隔离 body 的层叠上下文。功能类控制布局和层级,组件负责材质和外圆角([菜单规则](../../../docs/web-styling.zh.md#component-rules))。 模态遮罩保留黑色半透明填充,不模糊背景。 +`MenuGroup` 渲染以本地化标题命名的 `role="group"` 区段,标题 id 由各实例独立持有。自定义菜单与列表框共用其标题字体、间距和吸顶定位。标题原位透明;只有 `data-stuck` 才启用主题在浅/深色模式下的 94% 不透明分组标题填充。`data-platform="darwin"` 以外的标题使用 `--dsw-radius-md` 圆角;外围菜单保留半透明材质。 + +将 `MenuGroup` 区段渲染为滚动容器的直接子节点后,调用 `observeStickyMenuGroups(viewport)`。它同步更新状态,随后响应滚动、窗口尺寸变化,以及可用 `ResizeObserver` 观察到的滚动区或区段尺寸变化。只有区段跨过滚动区顶部且 `scrollTop` 为正时,标题才处于吸顶状态。分组成员在初始化时确定:渲染分组发生变化时(包括筛选),必须调用返回的清理函数并重新观察,卸载时也必须清理。清理会移除监听器、断开尺寸观察并清除受管理标题的 `data-stuck` 属性。没有直接分组的滚动区不会创建监听器或观察器。 +
实现细节——点击展开 diff --git a/packages/client/ui-primitives/src/MenuGroup.module.css b/packages/client/ui-primitives/src/MenuGroup.module.css new file mode 100644 index 0000000000..3065fdc27c --- /dev/null +++ b/packages/client/ui-primitives/src/MenuGroup.module.css @@ -0,0 +1,23 @@ +.group + .group { + margin-top: 3px; +} + +.heading { + position: sticky; + top: 0; + z-index: 1; + padding: 4px 7px 2px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 16px; + font-weight: 500; +} + +.heading[data-stuck] { + background: var(--dsw-alias-menu-group-header-fill); +} + +:global(html:not([data-platform='darwin'])) .heading { + border-radius: var(--dsw-radius-md); +} diff --git a/packages/client/ui-primitives/src/MenuGroup.tsx b/packages/client/ui-primitives/src/MenuGroup.tsx new file mode 100644 index 0000000000..ce77a4eab6 --- /dev/null +++ b/packages/client/ui-primitives/src/MenuGroup.tsx @@ -0,0 +1,56 @@ +/** Accessible menu groups with shared sticky-heading presentation and viewport observation. */ +import { useId, type ReactNode } from 'react' +import css from './MenuGroup.module.css' + +/** + * Render a named group with an instance-owned heading id. + * @param props - Caller-localized label and optional menu rows. + * @returns A section named by its direct heading, followed by the supplied children. + */ +export function MenuGroup({ label, children }: { label: string; children?: ReactNode }) { + const headingId = useId() + return ( +
+
{label}
+ {children} +
+ ) +} + +/** + * Update direct menu-group headings synchronously, then on scroll and resize. + * A heading is stuck only while its section straddles the viewport top and scrollTop is positive. + * Group membership is captured at setup; dispose and observe again when the rendered groups change. + * @param viewport - Scroll container whose direct groups contain direct data-menu-group-heading children. + * @returns Cleanup that removes listeners, disconnects size observation, and clears every managed + * heading's data-stuck attribute. A viewport without groups acquires no listeners or observer. + */ +export function observeStickyMenuGroups(viewport: HTMLElement): () => void { + const entries = [...viewport.querySelectorAll(':scope > [data-menu-group]')].flatMap((section) => { + const heading = section.querySelector(':scope > [data-menu-group-heading]') + return heading === null ? [] : [{ section, heading }] + }) + if (entries.length === 0) return () => {} + + const update = (): void => { + const top = viewport.getBoundingClientRect().top + // Section boxes retain their normal positions while their headings stick. + const stuck = entries.map(({ section }) => { + const bounds = section.getBoundingClientRect() + return viewport.scrollTop > 0 && bounds.top < top && bounds.bottom > top + }) + entries.forEach(({ heading }, index) => { heading.toggleAttribute('data-stuck', stuck[index]) }) + } + update() + viewport.addEventListener('scroll', update, { passive: true }) + window.addEventListener('resize', update) + const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(update) + observer?.observe(viewport) + for (const { section } of entries) observer?.observe(section) + return () => { + viewport.removeEventListener('scroll', update) + window.removeEventListener('resize', update) + observer?.disconnect() + for (const { heading } of entries) heading.removeAttribute('data-stuck') + } +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 57511f4dab..c6127472b7 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -25,6 +25,7 @@ export { Input } from './Input.tsx' export { Menu, MenuItemButton } from './Menu.tsx' export { MenuSurface } from './MenuSurface.tsx' export type { MenuSurfaceProps } from './MenuSurface.tsx' +export { MenuGroup, observeStickyMenuGroups } from './MenuGroup.tsx' export type { MenuItemButtonProps, MenuEntry, MenuItem, MenuSeparator, MenuLabel } from './Menu.tsx' export { useAnchoredMaxHeight } from './useAnchoredMaxHeight.ts' export { useAnchoredPosition } from './useAnchoredPosition.ts' diff --git a/packages/client/ui-primitives/tests/menu-group.client.spec.tsx b/packages/client/ui-primitives/tests/menu-group.client.spec.tsx new file mode 100644 index 0000000000..c444fb01ac --- /dev/null +++ b/packages/client/ui-primitives/tests/menu-group.client.spec.tsx @@ -0,0 +1,219 @@ +// @vitest-environment jsdom +/** Menu-group naming, sticky transitions, and viewport-observer ownership. */ +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MenuGroup, observeStickyMenuGroups } from '../src/MenuGroup.tsx' + +const disposers: (() => void)[] = [] + +beforeEach(() => { + vi.stubGlobal('ResizeObserver', undefined) +}) + +afterEach(() => { + for (const dispose of disposers.splice(0)) dispose() + cleanup() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +function observe(viewport: HTMLElement): () => void { + const dispose = observeStickyMenuGroups(viewport) + disposers.push(dispose) + return dispose +} + +function fixture() { + render( +
+ + +
, + ) + const viewport = screen.getByTestId('viewport') + const first = screen.getByRole('group', { name: 'Provider A' }) + const second = screen.getByRole('group', { name: 'Provider B' }) + const firstHeading = within(first).getByText('Provider A') + const secondHeading = within(second).getByText('Provider B') + const viewportRect = vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 100, 200, 200)) + const firstRect = vi.spyOn(first, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 100, 200, 100)) + const secondRect = vi.spyOn(second, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 203, 200, 100)) + return { viewport, first, second, firstHeading, secondHeading, viewportRect, firstRect, secondRect } +} + +describe('MenuGroup', () => { + it('names each section with a unique direct heading and preserves child order', () => { + const { first, second, firstHeading, secondHeading } = fixture() + expect(first.tagName).toBe('SECTION') + expect(first.hasAttribute('data-menu-group')).toBe(true) + expect(firstHeading.hasAttribute('data-menu-group-heading')).toBe(true) + expect(first.getAttribute('aria-labelledby')).toBe(firstHeading.id) + expect(second.getAttribute('aria-labelledby')).toBe(secondHeading.id) + expect(firstHeading.id).not.toBe('') + expect(firstHeading.id).not.toBe(secondHeading.id) + expect(first.firstElementChild).toBe(firstHeading) + expect(firstHeading.nextElementSibling).toBe(within(first).getByRole('button', { name: 'Model A' })) + }) + + it('renders a named group without rows', () => { + render() + const group = screen.getByRole('group', { name: 'Empty provider' }) + expect(group.children).toHaveLength(1) + expect(group.getAttribute('aria-labelledby')).toBe(within(group).getByText('Empty provider').id) + }) +}) + +describe('observeStickyMenuGroups', () => { + it('handles normal headings, sticking, handoff, and scrolling back without ResizeObserver', () => { + const { viewport, firstHeading, secondHeading, firstRect, secondRect } = fixture() + observe(viewport) + expect(firstHeading.hasAttribute('data-stuck')).toBe(false) + expect(secondHeading.hasAttribute('data-stuck')).toBe(false) + + viewport.scrollTop = 20 + firstRect.mockReturnValue(new DOMRect(0, 80, 200, 100)) + secondRect.mockReturnValue(new DOMRect(0, 183, 200, 100)) + fireEvent.scroll(viewport) + expect(firstHeading.hasAttribute('data-stuck')).toBe(true) + expect(secondHeading.hasAttribute('data-stuck')).toBe(false) + + viewport.scrollTop = 100 + firstRect.mockReturnValue(new DOMRect(0, 0, 200, 100)) + secondRect.mockReturnValue(new DOMRect(0, 103, 200, 100)) + fireEvent.scroll(viewport) + expect(firstHeading.hasAttribute('data-stuck')).toBe(false) + expect(secondHeading.hasAttribute('data-stuck')).toBe(false) + + viewport.scrollTop = 103 + secondRect.mockReturnValue(new DOMRect(0, 100, 200, 100)) + fireEvent.scroll(viewport) + expect(secondHeading.hasAttribute('data-stuck')).toBe(false) + + viewport.scrollTop = 104 + firstRect.mockReturnValue(new DOMRect(0, -4, 200, 100)) + secondRect.mockReturnValue(new DOMRect(0, 99, 200, 100)) + fireEvent.scroll(viewport) + expect(firstHeading.hasAttribute('data-stuck')).toBe(false) + expect(secondHeading.hasAttribute('data-stuck')).toBe(true) + + viewport.scrollTop = 0 + fireEvent.scroll(viewport) + expect(firstHeading.hasAttribute('data-stuck')).toBe(false) + expect(secondHeading.hasAttribute('data-stuck')).toBe(false) + }) + + it('synchronously updates an already-scrolled viewport and reads all boxes before writing attributes', () => { + const { viewport, firstHeading, secondHeading, viewportRect, firstRect, secondRect } = fixture() + viewport.scrollTop = 20 + firstRect.mockReturnValue(new DOMRect(0, 80, 200, 100)) + const firstWrite = vi.spyOn(firstHeading, 'toggleAttribute') + const secondWrite = vi.spyOn(secondHeading, 'toggleAttribute') + observe(viewport) + expect(firstHeading.hasAttribute('data-stuck')).toBe(true) + const reads = [...viewportRect.mock.invocationCallOrder, ...firstRect.mock.invocationCallOrder, ...secondRect.mock.invocationCallOrder] + const writes = [...firstWrite.mock.invocationCallOrder, ...secondWrite.mock.invocationCallOrder] + expect(Math.max(...reads)).toBeLessThan(Math.min(...writes)) + }) + + it('updates on window resize and removes scroll and resize listeners on cleanup', () => { + const { viewport, firstHeading, firstRect, viewportRect } = fixture() + const addScroll = vi.spyOn(viewport, 'addEventListener') + const removeScroll = vi.spyOn(viewport, 'removeEventListener') + const removeResize = vi.spyOn(window, 'removeEventListener') + const dispose = observe(viewport) + expect(addScroll).toHaveBeenCalledWith('scroll', expect.any(Function), { passive: true }) + + viewport.scrollTop = 20 + firstRect.mockReturnValue(new DOMRect(0, 80, 200, 100)) + fireEvent.resize(window) + expect(firstHeading.hasAttribute('data-stuck')).toBe(true) + viewportRect.mockReturnValue(new DOMRect(0, 70, 200, 200)) + fireEvent.resize(window) + expect(firstHeading.hasAttribute('data-stuck')).toBe(false) + viewportRect.mockReturnValue(new DOMRect(0, 100, 200, 200)) + fireEvent.resize(window) + expect(firstHeading.hasAttribute('data-stuck')).toBe(true) + + dispose() + expect(firstHeading.hasAttribute('data-stuck')).toBe(false) + expect(removeScroll).toHaveBeenCalledWith('scroll', expect.any(Function)) + expect(removeResize).toHaveBeenCalledWith('resize', expect.any(Function)) + firstRect.mockClear() + fireEvent.scroll(viewport) + fireEvent.resize(window) + expect(firstRect).not.toHaveBeenCalled() + expect(firstHeading.hasAttribute('data-stuck')).toBe(false) + }) + + it('observes the viewport and every section when ResizeObserver is available, then disconnects', () => { + const observeSize = vi.fn() + const disconnect = vi.fn() + let notify: () => void = () => { throw new Error('ResizeObserver has not been constructed') } + class StubResizeObserver implements ResizeObserver { + observe = observeSize + unobserve = vi.fn() + disconnect = disconnect + constructor(callback: ResizeObserverCallback) { + notify = () => { callback([], this) } + } + } + vi.stubGlobal('ResizeObserver', StubResizeObserver) + const { viewport, first, second, firstHeading, secondHeading, firstRect, secondRect } = fixture() + const dispose = observe(viewport) + expect(observeSize.mock.calls).toEqual([[viewport], [first], [second]]) + + viewport.scrollTop = 20 + firstRect.mockReturnValue(new DOMRect(0, 80, 200, 100)) + notify() + expect(firstHeading.hasAttribute('data-stuck')).toBe(true) + firstRect.mockReturnValue(new DOMRect(0, -20, 200, 100)) + secondRect.mockReturnValue(new DOMRect(0, 83, 200, 100)) + notify() + expect(firstHeading.hasAttribute('data-stuck')).toBe(false) + expect(secondHeading.hasAttribute('data-stuck')).toBe(true) + + dispose() + expect(disconnect).toHaveBeenCalledOnce() + expect(firstHeading.hasAttribute('data-stuck')).toBe(false) + expect(secondHeading.hasAttribute('data-stuck')).toBe(false) + }) + + it('ignores nested groups, unmarked sections, and headings that are not direct children', () => { + render( +
+ + + +
Indirect
+
Unmarked
+
, + ) + const viewport = screen.getByTestId('viewport') + const direct = screen.getByRole('group', { name: 'Direct' }) + const nested = screen.getByRole('group', { name: 'Nested' }) + vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 100, 200, 200)) + vi.spyOn(direct, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 80, 200, 100)) + const nestedRect = vi.spyOn(nested, 'getBoundingClientRect') + viewport.scrollTop = 20 + observe(viewport) + expect(screen.getByText('Direct').hasAttribute('data-stuck')).toBe(true) + expect(screen.getByText('Nested').hasAttribute('data-stuck')).toBe(false) + expect(screen.getByText('Indirect').hasAttribute('data-stuck')).toBe(false) + expect(screen.getByText('Unmarked').hasAttribute('data-stuck')).toBe(false) + expect(nestedRect).not.toHaveBeenCalled() + }) + + it('acquires no listeners or ResizeObserver for a viewport without direct groups', () => { + render(
) + const viewport = screen.getByTestId('viewport') + const addScroll = vi.spyOn(viewport, 'addEventListener') + const addResize = vi.spyOn(window, 'addEventListener') + const ResizeObserverStub = vi.fn() + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + const dispose = observe(viewport) + expect(addScroll).not.toHaveBeenCalled() + expect(addResize).not.toHaveBeenCalled() + expect(ResizeObserverStub).not.toHaveBeenCalled() + expect(dispose).not.toThrow() + }) +}) From 3ec8cd925d59b9fc75f69faa7777fb030bf61198 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 28 Sep 2026 18:12:48 +0800 Subject: [PATCH 19/42] perf(web): observe model menu stickiness without layout reads --- apps/web/tests/model-menu-observer.e2e.ts | 289 +++++++++++ apps/web/tsconfig.json | 1 + packages/client/ui-commands/README.i18n.yaml | 4 +- packages/client/ui-commands/README.md | 2 +- packages/client/ui-commands/README.zh.md | 2 +- .../src/client/PopupSelectView.tsx | 4 +- .../ui-model-selection/README.i18n.yaml | 4 +- packages/client/ui-model-selection/README.md | 2 +- .../client/ui-model-selection/README.zh.md | 2 +- .../src/client/ModelSelect.tsx | 2 +- .../tests/model-select.client.spec.tsx | 40 -- .../client/ui-primitives/README.i18n.yaml | 8 +- packages/client/ui-primitives/README.md | 6 +- packages/client/ui-primitives/README.zh.md | 6 +- .../ui-primitives/src/MenuGroup.module.css | 14 + .../client/ui-primitives/src/MenuGroup.tsx | 105 ++-- .../tests/menu-group.client.spec.tsx | 481 ++++++++++++------ tsconfig.host.json | 1 + 18 files changed, 729 insertions(+), 244 deletions(-) create mode 100644 apps/web/tests/model-menu-observer.e2e.ts diff --git a/apps/web/tests/model-menu-observer.e2e.ts b/apps/web/tests/model-menu-observer.e2e.ts new file mode 100644 index 0000000000..5da1f9e2f2 --- /dev/null +++ b/apps/web/tests/model-menu-observer.e2e.ts @@ -0,0 +1,289 @@ +/** Built-profile browser coverage for sticky model groups; counts only their synchronous rect reads, not FPS or latency. */ +import { fileURLToPath } from 'node:url' +import type { Browser, Locator, Page } from 'playwright' +import { chromium, webkit } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' +import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts' + +declare global { + interface Window { + /** Test-private count for MenuGroup sections and their direct scroll viewport. */ + __modelMenuRectReads: number + } +} + +const OVERLAY = fileURLToPath(new URL('./default-model.overlay.yml', import.meta.url)) +const PROVIDERS = [ + { id: 'origin-gateway', name: 'Origin Gateway', prefix: 'Origin' }, + { id: 'amber-gateway', name: 'Amber Gateway', prefix: 'Amber' }, + { id: 'cedar-gateway', name: 'Cedar Gateway', prefix: 'Cedar' }, + { id: 'quartz-gateway', name: 'Quartz Gateway', prefix: 'Quartz' }, +].map((provider, providerIndex) => ({ + ...provider, + models: Array.from({ length: 7 }, (_, index) => providerIndex === 0 && index === 0 + ? { id: 'origin-large', name: 'Origin Large' } + : { id: `${provider.prefix.toLowerCase()}-model-${index + 1}`, name: `${provider.prefix} Model ${index + 1}` }), +})) +const EXPECTED_GROUPS = [ + { label: 'DeepSeek', rows: ['DeepSeek-V4-Flash', 'DeepSeek-V4-Flash-Vision-Exp'] }, + ...PROVIDERS.map(provider => ({ label: provider.name, rows: provider.models.map(model => model.name) })), +] + +// Native IO/RO callbacks need rendering opportunities, not elapsed-time sleeps. +async function frames(page: Page): Promise { + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) }) + })) +} + +function readGroups(viewport: Locator) { + return viewport.locator('[data-menu-group]').evaluateAll(groups => groups.map(group => ({ + label: group.querySelector('[data-menu-group-heading]')!.textContent, + rows: [...group.querySelectorAll('[role="option"], [role="menuitemradio"]')].map(row => row.textContent), + }))) +} + +function readPinned(viewport: Locator) { + return viewport.locator('[data-menu-group-heading]').evaluateAll(headings => + headings.map(heading => heading.hasAttribute('data-stuck'))) +} + +async function assertNoRectReads(page: Page): Promise { + await frames(page) + expect(await page.evaluate(() => window.__modelMenuRectReads)).toBe(0) +} + +async function assertPinned(page: Page, viewport: Locator, index: number, count = 5): Promise { + const expected = Array.from({ length: count }, (_, i) => i === index) + await expect.poll(() => readPinned(viewport)).toEqual(expected) + await assertNoRectReads(page) + expect(await readPinned(viewport)).toEqual(expected) +} + +async function scrollToGroup(viewport: Locator, index: number, inside = 1): Promise { + // Sections share an offset parent. Their normal-flow offsets are test positioning, not rect instrumentation. + await viewport.evaluate((node, target) => new Promise((resolve) => { + requestAnimationFrame(() => { + const groups = node.querySelectorAll(':scope > [data-menu-group]') + node.scrollTop = groups[target.index]!.offsetTop - groups[0]!.offsetTop + target.inside + resolve() + }) + }), { index, inside }) +} + +async function headingMaterial(viewport: Locator) { + return viewport.locator('[data-menu-group-heading]').first().evaluate((heading) => { + const body = heading.ownerDocument.body + const theme = body.getAttribute('data-ds-dark-theme') + try { + return [false, true].map((dark) => { + body.toggleAttribute('data-ds-dark-theme', dark) + const style = getComputedStyle(heading) + return { + fill: style.backgroundColor, radius: style.borderRadius, position: style.position, + font: style.fontSize, weight: style.fontWeight, padding: style.padding, + } + }) + } finally { + if (theme === null) body.removeAttribute('data-ds-dark-theme') + else body.setAttribute('data-ds-dark-theme', theme) + } + }) +} + +describe.skipIf(webSnapshotMode() === 'record').each([ + { name: 'Chromium', engine: chromium }, + { name: 'WebKit', engine: webkit }, +])('web e2e: model menu observers ($name)', ({ engine }) => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let crashed = false + + beforeAll(async () => { + scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY }) + // Catalog-only settings traffic: no prompt, selection, replay fixture, or model request. + await scaffold.ctx.settings.update('llm-pi-ai', { + providers: Object.fromEntries(PROVIDERS.map(provider => [provider.id, { + displayName: provider.name, + api: 'openai-completions', + baseURL: `https://${provider.id}.example/v1`, + models: provider.models, + }])), + }) + browser = await engine.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + await page.addInitScript(() => { + window.__modelMenuRectReads = 0 + // oxlint-disable-next-line typescript/unbound-method -- The wrapper supplies the original Element receiver below. + const original = Element.prototype.getBoundingClientRect + Element.prototype.getBoundingClientRect = function (): DOMRect { + if (this.hasAttribute('data-menu-group') || [...this.children].some(child => child.hasAttribute('data-menu-group'))) { + window.__modelMenuRectReads++ + } + return original.call(this) + } + }) + page.on('crash', () => { crashed = true; console.error('observer test page crashed') }) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) + }) + + afterAll(async () => { + try { + await browser?.close() + } finally { + await scaffold?.close() + } + }) + + it('keeps both pickers ordered, sticky and keyboard-operable without synchronous group rect reads', async () => { + onTestFailed(() => saveFailureShot(page, `web-e2e-model-menu-observer-${engine.name()}`)) + let buttonMaterial: Awaited> | undefined + for (const entry of ['button', 'command'] as const) { + const composer = page.locator('[data-composer-input]').first() + const surface = entry === 'button' + ? page.getByRole('group', { name: '模型与推理等级', exact: true }) + : page.locator('[aria-label="/model 选项"]') + const viewport = entry === 'button' + ? surface.getByRole('menu', { name: '模型', exact: true }) + : surface.getByRole('listbox') + const search = entry === 'button' + ? page.getByRole('searchbox', { name: '搜索模型…', exact: true }) + : page.getByRole('textbox', { name: '筛选选项', exact: true }) + const open = async (): Promise => { + if (entry === 'button') { + await page.getByRole('button', { name: /^选择模型/ }).click() + await page.getByRole('menuitem', { name: /^模型/ }).click() + } else { + await page.getByRole('button', { name: '添加文件或调用指令', exact: true }).click() + await page.getByRole('option', { name: /^模型/ }).click() + } + await expect.poll(() => readGroups(viewport)).toEqual(EXPECTED_GROUPS) + await expect.poll(() => search.evaluate(node => node === node.ownerDocument.activeElement)).toBe(true) + await assertNoRectReads(page) + await page.mouse.move(0, 0) + await viewport.evaluate((node) => { node.style.maxHeight = '80px'; node.scrollTop = 0 }) + await assertPinned(page, viewport, -1) + } + const close = async (): Promise => { + if (crashed || page.isClosed() || await surface.count() === 0) return + await search.press('Escape') + if (entry === 'button') await page.keyboard.press('Escape') + await surface.waitFor({ state: 'detached' }) + await composer.fill('') + } + + await open() + try { + expect(await viewport.locator('[role="option"], [role="menuitemradio"]').count()).toBe(30) + expect(await viewport.evaluate(node => node.scrollTop)).toBe(0) + expect(await viewport.locator('[data-menu-group-heading]').evaluateAll(headings => + [...new Set(headings.map(heading => getComputedStyle(heading).backgroundColor))])) + .toEqual(['rgba(0, 0, 0, 0)']) + await scrollToGroup(viewport, 0) + await assertPinned(page, viewport, 0) + const material = await headingMaterial(viewport) + expect(material.map(style => style.fill)).toEqual(['rgba(248, 249, 250, 0.94)', 'rgba(48, 49, 54, 0.94)']) + for (const style of material) { + expect(style).toMatchObject({ position: 'sticky', font: '11px', weight: '500', padding: '4px 7px 2px' }) + } + if (entry === 'button') buttonMaterial = material + else expect(material).toEqual(buttonMaterial) + + for (const index of [2, 4, 1]) { + await scrollToGroup(viewport, index) + await assertPinned(page, viewport, index) + const height = await viewport.evaluate(node => node.clientHeight) + expect(height).toBeGreaterThan(0) + expect(height).toBeLessThanOrEqual(80) + expect(await viewport.locator('[data-menu-group]').nth(index).evaluate(node => node.clientHeight)) + .toBeGreaterThan(height * 2) + await scrollToGroup(viewport, index, height + 1) + await assertPinned(page, viewport, index) + expect(await viewport.evaluate((node, groupIndex) => { + const headings = node.querySelectorAll('[data-menu-group-heading]') + const firstStart = node.querySelector('[data-menu-group-start]')! + const viewportTop = firstStart.getBoundingClientRect().top + node.scrollTop + return Math.abs(headings[groupIndex]!.getBoundingClientRect().top - viewportTop) + }, index)).toBeLessThan(1) + } + await scrollToGroup(viewport, 0, 0) + await assertPinned(page, viewport, -1) + + for (const [key, steps] of [['ArrowDown', 18], ['ArrowUp', 10]] as const) { + const visited = new Set() + let observedPinnedHighlight = false + for (let step = 0; step < steps; step++) { + const rows = viewport.locator('[role="option"], [role="menuitemradio"]') + const before = await rows.evaluateAll(nodes => nodes.findIndex(node => + node.hasAttribute('data-highlighted') || node.getAttribute('aria-selected') === 'true')) + await search.press(key) + const expected = (before + (key === 'ArrowDown' ? 1 : -1) + 30) % 30 + await expect.poll(() => rows.evaluateAll(nodes => nodes.findIndex(node => + node.hasAttribute('data-highlighted') || node.getAttribute('aria-selected') === 'true'))).toBe(expected) + await frames(page) + await expect.poll(() => viewport.evaluate((node) => { + const groups = [...node.querySelectorAll(':scope > [data-menu-group]')] + const base = groups[0]!.offsetTop + return groups.every((group) => { + const top = group.offsetTop - base + const shouldStick = top < node.scrollTop && top + group.offsetHeight > node.scrollTop + return group.querySelector('[data-menu-group-heading]')!.hasAttribute('data-stuck') === shouldStick + }) + })).toBe(true) + const highlight = await viewport.evaluate((node) => { + const groups = [...node.querySelectorAll(':scope > [data-menu-group]')] + const index = groups.findIndex(group => group.querySelector('[data-highlighted], [aria-selected="true"]') !== null) + const group = groups[index]! + return { index, above: group.offsetTop - groups[0]!.offsetTop < node.scrollTop, + stuck: group.querySelector('[data-menu-group-heading]')!.hasAttribute('data-stuck') } + }) + visited.add(highlight.index) + if (highlight.above) { + expect(highlight.stuck).toBe(true) + observedPinnedHighlight = true + } + expect(await search.evaluate(node => node === node.ownerDocument.activeElement)).toBe(true) + await assertNoRectReads(page) + } + expect(visited.size).toBeGreaterThan(1) + expect(observedPinnedHighlight).toBe(true) + } + + await scrollToGroup(viewport, 0) + await assertPinned(page, viewport, 0) + await search.fill('Quartz Model 1') + await expect.poll(() => readGroups(viewport)).toEqual([{ label: 'Quartz Gateway', rows: ['Quartz Model 1'] }]) + await assertPinned(page, viewport, -1, 1) + expect(await viewport.evaluate(node => node.scrollTop)).toBe(0) + await search.fill('') + await expect.poll(() => readGroups(viewport)).toEqual(EXPECTED_GROUPS) + await assertPinned(page, viewport, -1) + await close() + await open() + expect(await search.inputValue()).toBe('') + + await scrollToGroup(viewport, 3, 90) + await assertPinned(page, viewport, 3) + await viewport.evaluate((node) => { node.style.maxHeight = '64px' }) + await expect.poll(() => viewport.evaluate(node => node.clientHeight)).toBeLessThanOrEqual(64) + await assertPinned(page, viewport, 3) + await scrollToGroup(viewport, 4, 100) + await assertPinned(page, viewport, 4) + await scrollToGroup(viewport, 1, 100) + await assertPinned(page, viewport, 1) + await scrollToGroup(viewport, 0, 0) + await assertPinned(page, viewport, -1) + } finally { + await close() + } + } + expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'origin-gateway', model: 'origin-large' }) + expect(tripwire.pageErrors).toEqual([]) + }) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index f5f0300f04..e213a2ab14 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -87,6 +87,7 @@ "tests/deepseek-messages-chat.e2e.ts", "tests/models-settings-recovery.e2e.ts", "tests/default-model.e2e.ts", + "tests/model-menu-observer.e2e.ts", "tests/default-workspace.e2e.ts", "tests/new-session-refused.e2e.ts", "tests/github-ready-review.e2e.ts", diff --git a/packages/client/ui-commands/README.i18n.yaml b/packages/client/ui-commands/README.i18n.yaml index 0b344dbeac..ba51e078fb 100644 --- a/packages/client/ui-commands/README.i18n.yaml +++ b/packages/client/ui-commands/README.i18n.yaml @@ -27,8 +27,8 @@ en: 4feecdbec0c3f39e zh: 618944ecade8a3a2 /deepseek-ai-dsh-client-ui-commands/understand-the-implementation: - en: 9fffb036b0346871 - zh: 1e0364e9b4f5a24b + en: 3aeeba6c91c3ef19 + zh: d3afb3df6d15e17b /deepseek-ai-dsh-client-ui-commands/further-exploration: en: 409c04f534d23de7 zh: 165022d7a0a554eb diff --git a/packages/client/ui-commands/README.md b/packages/client/ui-commands/README.md index 0cba9477f4..eb354816d2 100644 --- a/packages/client/ui-commands/README.md +++ b/packages/client/ui-commands/README.md @@ -50,7 +50,7 @@ When the composer submits with images or generic files, only a host command decl ## Understand the implementation -Popup selection panels fill the composer overlay's width, matching the slash menu. Popup search fields keep transparent backgrounds and borders in both palettes. Grouped options use [ui-primitives](../ui-primitives/README.md#understand-the-implementation)' shared `MenuGroup` headings and `observeStickyMenuGroups`; the view cleans up and rebuilds the observer when rendered groups change. Menus use the shared `MenuSurface` material, including the macOS backing for background blur; custom content follows the [menu rules](../../../docs/web-styling.md#component-rules). +Popup selection panels fill the composer overlay's width, matching the slash menu. Popup search fields keep transparent backgrounds and borders in both palettes. Grouped options use [ui-primitives](../ui-primitives/README.md#understand-the-implementation)' shared `MenuGroup` headings and `observeStickyMenuGroups`; an ordinary effect owns the asynchronous observation and rebuilds it when rendered groups change. Menus use the shared `MenuSurface` material, including the macOS backing for background blur; custom content follows the [menu rules](../../../docs/web-styling.md#component-rules).
Implementation internals — click to expand diff --git a/packages/client/ui-commands/README.zh.md b/packages/client/ui-commands/README.zh.md index bfa03d9093..de78e36758 100644 --- a/packages/client/ui-commands/README.zh.md +++ b/packages/client/ui-commands/README.zh.md @@ -50,7 +50,7 @@ composer 携带图片或通用文件提交时,只有声明了 `input.attachmen ## 理解实现 -弹窗选择面板撑满 composer 浮层宽度,与斜杠菜单一致。弹窗搜索框在浅/深色主题下均保持背景和边框透明。分组选项使用 [ui-primitives](../ui-primitives/README.zh.md#understand-the-implementation) 共享的 `MenuGroup` 标题与 `observeStickyMenuGroups`;渲染分组变化时,视图清理并重建观察器。菜单采用共享 `MenuSurface` 材质,包括用于背景模糊的 macOS 底层;自定义内容遵循[菜单规则](../../../docs/web-styling.zh.md#component-rules)。 +弹窗选择面板撑满 composer 浮层宽度,与斜杠菜单一致。弹窗搜索框在浅/深色主题下均保持背景和边框透明。分组选项使用 [ui-primitives](../ui-primitives/README.zh.md#understand-the-implementation) 共享的 `MenuGroup` 标题与 `observeStickyMenuGroups`;普通 effect 负责异步观察,并在渲染分组变化时清理和重建观察器。菜单采用共享 `MenuSurface` 材质,包括用于背景模糊的 macOS 底层;自定义内容遵循[菜单规则](../../../docs/web-styling.zh.md#component-rules)。
实现细节——点击展开 diff --git a/packages/client/ui-commands/src/client/PopupSelectView.tsx b/packages/client/ui-commands/src/client/PopupSelectView.tsx index 355b913502..4b0b4bf418 100644 --- a/packages/client/ui-commands/src/client/PopupSelectView.tsx +++ b/packages/client/ui-commands/src/client/PopupSelectView.tsx @@ -11,7 +11,7 @@ * above the composer. */ import { MenuGroup, MenuSurface, observeStickyMenuGroups } from '@deepseek-ai/dsh-client-ui-primitives' -import { Fragment, useEffect, useLayoutEffect, useMemo, useRef } from 'react' +import { Fragment, useEffect, useMemo, useRef } from 'react' import { useSyncExternalStore } from 'react' import clsx from 'clsx' import { IconCheckOutlineRegular, RiskConfirmation, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' @@ -50,7 +50,7 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) { const rows = useMemo(() => filterOptions(state.options, state.search, state.searchMode), [state.options, state.search, state.searchMode]) const groups = useMemo(() => groupOptions(rows), [rows]) - useLayoutEffect(() => { + useEffect(() => { const viewport = viewportRef.current if (viewport === null) return return observeStickyMenuGroups(viewport) diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index 804d66694f..9c22fdfbdf 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/README.i18n.yaml @@ -18,8 +18,8 @@ en: 62235f8c8c9e1e7e zh: c18dc9c38ef9d4f9 /deepseek-ai-dsh-client-ui-model-selection/use-this-package/model-and-effort: - en: 5e2ec95807548cb4 - zh: b47754c9cdf2d068 + en: 48f8157ffdfe6e69 + zh: 64ec9a533e2aa8f4 /deepseek-ai-dsh-client-ui-model-selection/use-this-package/unroutable-sessions: en: 7f20428c747ff052 zh: 975e6de728e02fa0 diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index 77080c3a4d..09b016ef3a 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -41,7 +41,7 @@ The button's model menu shows search only when its full catalog contains more th Model and reasoning-effort names use weight 400 (regular) in the composer menu, including the selected item. The search field follows the command popup's compact treatment with transparent background and border in both palettes, with no leading icon and a caption-tone placeholder. The clear button appears for a nonempty query and restores the full list with focus in the search field. -Both entries group models by provider, with DeepSeek Account first and DeepSeek second; third-party providers retain their catalog order. Both use the shared sticky-heading presentation from [ui-primitives](../ui-primitives/README.md#understand-the-implementation): transparent at rest, with the theme's 94%-opaque fill only while pinned, and `md` corners outside macOS Desktop. The composer menu shows model and effort names only. Navigation chevrons use `--dsw-alias-menu-icon`. The `/model` popup shows provider names as group headings and model names as rows, without repeating the provider on each row or showing catalog descriptions. Its search placeholder, no-match text, and empty-catalog text use the same localized labels as the composer model menu. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. +Both entries group models by provider, with DeepSeek Account first and DeepSeek second; third-party providers retain their catalog order. Both use the shared, asynchronously observed sticky headings from [ui-primitives](../ui-primitives/README.md#understand-the-implementation): transparent at rest, with the theme's 94%-opaque fill only while pinned, and `md` corners outside macOS Desktop. The composer menu shows model and effort names only. Navigation chevrons use `--dsw-alias-menu-icon`. The `/model` popup shows provider names as group headings and model names as rows, without repeating the provider on each row or showing catalog descriptions. Its search placeholder, no-match text, and empty-catalog text use the same localized labels as the composer model menu. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. The composer replaces the model and effort text with the Models icon when the expanded controls cannot share one line, and restores the text when space permits. The full selection remains available in the trigger's accessible name, tooltip, and menu. diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index 9894348717..12df7a98ed 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -41,7 +41,7 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示 composer 菜单中的模型名称和推理等级均使用 400(regular)字重,包括选中项。搜索框沿用 command 弹窗的紧凑样式,在浅/深色主题下均保持背景和边框透明,不显示前置图标,占位文字使用 caption 色。搜索内容非空时显示清除按钮;点击后恢复完整列表,并将焦点留在搜索框。 -两个入口均按提供方分组,DeepSeek 账号排第一,DeepSeek 排第二,第三方提供方保持目录原有顺序。两者采用 [ui-primitives](../ui-primitives/README.zh.md#understand-the-implementation) 的共享吸顶标题:原位透明,仅吸顶时使用主题的 94% 不透明填充,macOS 桌面端以外使用 `md` 圆角。composer 菜单只显示模型与推理强度名称。导航箭头使用 `--dsw-alias-menu-icon` 文本色。`/model` 弹窗以提供方名称作为分组标题、模型名称作为选项行,不在每行重复提供方,也不显示目录说明。搜索占位文字、无匹配提示和空目录提示与 composer 模型菜单共用同一组本地化文案。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 +两个入口均按提供方分组,DeepSeek 账号排第一,DeepSeek 排第二,第三方提供方保持目录原有顺序。两者采用 [ui-primitives](../ui-primitives/README.zh.md#understand-the-implementation) 异步观察的共享吸顶标题:原位透明,仅吸顶时使用主题的 94% 不透明填充,macOS 桌面端以外使用 `md` 圆角。composer 菜单只显示模型与推理强度名称。导航箭头使用 `--dsw-alias-menu-icon` 文本色。`/model` 弹窗以提供方名称作为分组标题、模型名称作为选项行,不在每行重复提供方,也不显示目录说明。搜索占位文字、无匹配提示和空目录提示与 composer 模型菜单共用同一组本地化文案。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 展开的控件无法排在同一行时,composer 将模型与推理强度文字替换为模型图标;空间足够后恢复文字。触发器的无障碍名称、提示和菜单仍提供完整选择。 diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index f3524df753..fbeda5d31e 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -191,7 +191,7 @@ export function ModelSelect( ;(cell !== null && cell !== undefined && !cell.disabled ? cell : triggerRef.current)?.focus() }, [open, pane, showSearch]) - useLayoutEffect(() => { + useEffect(() => { const viewport = groupsRef.current if (viewport === null) return return observeStickyMenuGroups(viewport) diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index 0341daa436..470c852ec8 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -852,46 +852,6 @@ describe('ModelSelect search', () => { }) }) -it('paints only a pinned provider heading and clears its state when the pane closes', () => { - const directory = createSnapshotStore(state({ groups: [ - ...state().groups, - { id: 'other', name: 'Other', models: [{ id: 'gemini', name: 'Gemini Flash' }] }, - ] })) - render() - const trigger = screen.getByRole('button', { name: /选择模型/ }) - fireEvent.click(trigger) - fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) - const viewport = screen.getByRole('menu', { name: '模型' }) - const sections = [...viewport.querySelectorAll('section')] - const headings = sections.map(section => section.querySelector(':scope > div')!) - let firstHeight = 90 - vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 100, 240, 80)) - vi.spyOn(sections[0]!, 'getBoundingClientRect').mockImplementation(() => new DOMRect(0, 100 - viewport.scrollTop, 240, firstHeight)) - vi.spyOn(sections[1]!, 'getBoundingClientRect').mockImplementation(() => new DOMRect(0, 103 + firstHeight - viewport.scrollTop, 240, 56)) - const pinned = (): boolean[] => headings.map(heading => heading.hasAttribute('data-stuck')) - fireEvent.scroll(viewport, { target: { scrollTop: 0 } }) - expect(pinned()).toEqual([false, false]) - fireEvent.scroll(viewport, { target: { scrollTop: 24 } }) - expect(pinned()).toEqual([true, false]) - fireEvent.scroll(viewport, { target: { scrollTop: 93 } }) - expect(pinned()).toEqual([false, false]) - fireEvent.scroll(viewport, { target: { scrollTop: 94 } }) - expect(pinned()).toEqual([false, true]) - firstHeight = 130 - fireEvent.resize(window) - expect(pinned()).toEqual([true, false]) - fireEvent.scroll(viewport, { target: { scrollTop: 0 } }) - expect(pinned()).toEqual([false, false]) - fireEvent.scroll(viewport, { target: { scrollTop: 24 } }) - const removeScroll = vi.spyOn(viewport, 'removeEventListener') - fireEvent.click(trigger) - expect(removeScroll).toHaveBeenCalledWith('scroll', expect.any(Function)) - expect(pinned()).toEqual([false, false]) - fireEvent.click(trigger) - fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) - expect(screen.getByRole('menu', { name: '模型' }).querySelector('[data-stuck]')).toBeNull() -}) - it('shows the unselected model control with the inherited effort', async () => { const directory = createSnapshotStore(state({ current: null, routable: false, retainedEffort: 'High' })) render() diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 517377a20c..cff51aed21 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -18,8 +18,8 @@ en: 7bae72c97d0449f5 zh: 4589ecc2b216edc5 /deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog: - en: 91ab0b7b84d060be - zh: 6d6e2882f58e71f6 + en: 86ebc31d3af22478 + zh: 6f9f75d43e67049e /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: en: b4cd33720a6cf6b8 zh: 8db75bf633789c6d @@ -30,8 +30,8 @@ en: 4aef65dea11de083 zh: abc22cd7c65cdf38 /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation: - en: 6d228f9141c804aa - zh: 04bdfc8f3673fcd0 + en: 0b4082dbb778476f + zh: b6daaaf9de675b57 /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/source-map: en: 44eb32370cbeafe8 zh: 728009c66375c6bb diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 1e3116dc13..2ccc2b61e8 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -46,7 +46,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. | | `Input` | Single-line text entry for search boxes and inline forms. Its ref targets the native input for focus and is cleared on unmount. | | `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. | -| `MenuGroup`, `observeStickyMenuGroups` | Localized, accessible groups for custom menus and listboxes, with shared sticky-heading styling and scroll/resize observation; the caller owns the observer's lifetime. | +| `MenuGroup`, `observeStickyMenuGroups` | Localized, accessible groups for custom menus and listboxes, with shared sticky-heading styling and native intersection/size observation; the caller owns the observer's lifetime. | | `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. | | `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. | | `Tag` | Read-only capsule badge; `tone` selects one of eight palettes. | @@ -121,7 +121,9 @@ The atoms cannot read the application locale, so every piece of user-facing copy `MenuGroup` renders a `role="group"` section named by its localized heading, with an instance-owned heading id. It shares heading typography, spacing, and sticky positioning between custom menus and listboxes. Headings are transparent at rest; only `data-stuck` enables the theme's 94%-opaque group-header fill in either palette. Outside `data-platform="darwin"`, headings use `--dsw-radius-md` corners; the enclosing menu keeps its translucent material. -Call `observeStickyMenuGroups(viewport)` after rendering `MenuGroup` sections as direct children of the scroll container. It updates synchronously, then on scrolling, window resize, and viewport or section resizing when `ResizeObserver` is available. A heading is stuck only while its section straddles the viewport top and `scrollTop` is positive. Group membership is captured at setup: call the returned cleanup and observe again whenever rendered groups change, including filtering, and clean up on unmount. Cleanup removes listeners, disconnects size observation, and clears managed `data-stuck` attributes. A viewport without direct groups acquires no listeners or observer. +Call `observeStickyMenuGroups(viewport)` from an ordinary effect after rendering `MenuGroup` sections as direct children of an unpadded, borderless scroll container. Native intersection and viewport-size observations update the background asynchronously, without synchronous layout reads or scroll listeners. Headings remain transparent until observations identify a group crossing the viewport top; CSS owns their sticky positioning. + +Group membership is captured at setup. Dispose before observing changed groups, including after filtering, and clean up on unmount. Cleanup disconnects the observers, ignores queued callbacks, and clears managed `data-stuck` attributes. A viewport without direct groups acquires no observers. Without `IntersectionObserver` or `ResizeObserver`, headings keep CSS sticky positioning but remain transparent.
Implementation internals — click to expand diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 9dc080c933..588fb26041 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -46,7 +46,7 @@ kind: "package-library" | `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 | | `Input` | 单行文本输入,用于搜索框与行内表单。ref 指向原生输入框,供焦点控制使用,并在卸载时清空。 | | `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 | -| `MenuGroup`、`observeStickyMenuGroups` | 为自定义菜单与列表框提供本地化、可访问的分组,共用吸顶标题样式与滚动/尺寸观察;调用方负责观察器的生命周期。 | +| `MenuGroup`、`observeStickyMenuGroups` | 为自定义菜单与列表框提供本地化、可访问的分组,共用吸顶标题样式与原生交叉/尺寸观察;调用方负责观察器的生命周期。 | | `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 | | `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。 | | `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 | @@ -121,7 +121,9 @@ kind: "package-library" `MenuGroup` 渲染以本地化标题命名的 `role="group"` 区段,标题 id 由各实例独立持有。自定义菜单与列表框共用其标题字体、间距和吸顶定位。标题原位透明;只有 `data-stuck` 才启用主题在浅/深色模式下的 94% 不透明分组标题填充。`data-platform="darwin"` 以外的标题使用 `--dsw-radius-md` 圆角;外围菜单保留半透明材质。 -将 `MenuGroup` 区段渲染为滚动容器的直接子节点后,调用 `observeStickyMenuGroups(viewport)`。它同步更新状态,随后响应滚动、窗口尺寸变化,以及可用 `ResizeObserver` 观察到的滚动区或区段尺寸变化。只有区段跨过滚动区顶部且 `scrollTop` 为正时,标题才处于吸顶状态。分组成员在初始化时确定:渲染分组发生变化时(包括筛选),必须调用返回的清理函数并重新观察,卸载时也必须清理。清理会移除监听器、断开尺寸观察并清除受管理标题的 `data-stuck` 属性。没有直接分组的滚动区不会创建监听器或观察器。 +将 `MenuGroup` 区段渲染为无内边距、无边框的滚动容器的直接子节点后,在普通 effect 中调用 `observeStickyMenuGroups(viewport)`。原生交叉观察与滚动区尺寸观察异步更新背景,不同步读取布局,也不注册滚动监听器。标题保持透明,直到观察结果确认分组跨过滚动区顶部;吸顶定位由 CSS 负责。 + +分组成员在初始化时确定。分组变化后(包括筛选)重新观察前必须清理,卸载时也必须清理。清理会断开观察器、忽略排队回调并清除受管理标题的 `data-stuck` 属性。没有直接分组的滚动区不会创建观察器。缺少 `IntersectionObserver` 或 `ResizeObserver` 时,标题仍通过 CSS 吸顶,但保持透明。
实现细节——点击展开 diff --git a/packages/client/ui-primitives/src/MenuGroup.module.css b/packages/client/ui-primitives/src/MenuGroup.module.css index 3065fdc27c..f4b054e9bd 100644 --- a/packages/client/ui-primitives/src/MenuGroup.module.css +++ b/packages/client/ui-primitives/src/MenuGroup.module.css @@ -1,3 +1,17 @@ +.group { + position: relative; +} + +.start { + position: absolute; + top: 0; + left: 0; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + .group + .group { margin-top: 3px; } diff --git a/packages/client/ui-primitives/src/MenuGroup.tsx b/packages/client/ui-primitives/src/MenuGroup.tsx index ce77a4eab6..bd26bc0249 100644 --- a/packages/client/ui-primitives/src/MenuGroup.tsx +++ b/packages/client/ui-primitives/src/MenuGroup.tsx @@ -3,7 +3,7 @@ import { useId, type ReactNode } from 'react' import css from './MenuGroup.module.css' /** - * Render a named group with an instance-owned heading id. + * Render a named group with an instance-owned heading id and an inaccessible position sentinel. * @param props - Caller-localized label and optional menu rows. * @returns A section named by its direct heading, followed by the supplied children. */ @@ -11,6 +11,7 @@ export function MenuGroup({ label, children }: { label: string; children?: React const headingId = useId() return (
+
@@ -18,39 +19,85 @@ export function MenuGroup({ label, children }: { label: string; children?: React } /** - * Update direct menu-group headings synchronously, then on scroll and resize. - * A heading is stuck only while its section straddles the viewport top and scrollTop is positive. - * Group membership is captured at setup; dispose and observe again when the rendered groups change. - * @param viewport - Scroll container whose direct groups contain direct data-menu-group-heading children. - * @returns Cleanup that removes listeners, disconnects size observation, and clears every managed - * heading's data-stuck attribute. A viewport without groups acquires no listeners or observer. + * Update heading backgrounds asynchronously from native intersection and viewport-size observations. + * Headings remain transparent until observations identify a section crossing the viewport top. + * Without IntersectionObserver or ResizeObserver, CSS sticky headings remain transparent. + * Group membership is captured at setup; dispose before observing changed groups or the same viewport again. + * @param viewport - Unpadded, borderless scroll container with direct MenuGroup children. + * @returns Cleanup owning both intersection observers and the viewport resize observer; disconnects + * them, ignores queued callbacks, and clears managed data-stuck attributes. No valid groups acquires nothing. */ export function observeStickyMenuGroups(viewport: HTMLElement): () => void { - const entries = [...viewport.querySelectorAll(':scope > [data-menu-group]')].flatMap((section) => { + const groups = [...viewport.querySelectorAll(':scope > [data-menu-group]')].flatMap((section) => { const heading = section.querySelector(':scope > [data-menu-group-heading]') - return heading === null ? [] : [{ section, heading }] + const start = section.querySelector(':scope > [data-menu-group-start]') + return heading === null || start === null ? [] : [{ + section, heading, start, atTop: false, above: false, topTime: -Infinity, aboveTime: -Infinity, + }] }) - if (entries.length === 0) return () => {} - - const update = (): void => { - const top = viewport.getBoundingClientRect().top - // Section boxes retain their normal positions while their headings stick. - const stuck = entries.map(({ section }) => { - const bounds = section.getBoundingClientRect() - return viewport.scrollTop > 0 && bounds.top < top && bounds.bottom > top - }) - entries.forEach(({ heading }, index) => { heading.toggleAttribute('data-stuck', stuck[index]) }) + if (groups.length === 0 || typeof IntersectionObserver === 'undefined' || typeof ResizeObserver === 'undefined') { + return () => {} } - update() - viewport.addEventListener('scroll', update, { passive: true }) - window.addEventListener('resize', update) - const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(update) - observer?.observe(viewport) - for (const { section } of entries) observer?.observe(section) + + const sections = new Map(groups.map(group => [group.section, group])) + const starts = new Map(groups.map(group => [group.start, group])) + let disposed = false + let stripObserver: IntersectionObserver | undefined + let viewportHeight: number | undefined + const render = (group: typeof groups[number]): void => { + const stuck = group.atTop && group.above + if (group.heading.hasAttribute('data-stuck') !== stuck) group.heading.toggleAttribute('data-stuck', stuck) + } + const startObserver = new IntersectionObserver((entries) => { + if (disposed) return + for (const entry of entries) { + const group = starts.get(entry.target) + if (!group || entry.rootBounds === null || entry.time < group.aboveTime) continue + group.above = entry.boundingClientRect.top < entry.rootBounds.top + group.aboveTime = entry.time + render(group) + } + }, { root: viewport, threshold: [0, 1] }) + for (const { start } of groups) startObserver.observe(start) + + const sizeObserver = new ResizeObserver((entries) => { + if (disposed) return + for (const entry of entries) { + if (entry.target !== viewport || entry.contentRect.height === viewportHeight) continue + viewportHeight = entry.contentRect.height + stripObserver?.disconnect() + // A full section crossing this strip is observable even when scrolling skips its start sentinel. + const observer = new IntersectionObserver((intersections) => { + if (disposed || stripObserver !== observer) return + for (const intersection of intersections) { + const group = sections.get(intersection.target) + if (!group || intersection.rootBounds === null) continue + const top = intersection.rootBounds.top + if (intersection.time >= group.topTime) { + group.atTop = intersection.isIntersecting && intersection.boundingClientRect.bottom > top + group.topTime = intersection.time + } + // Both observers describe the section's normal-flow start; the newest sample wins. + if (intersection.time >= group.aboveTime) { + group.above = intersection.boundingClientRect.top < top + group.aboveTime = intersection.time + } + render(group) + } + }, { root: viewport, rootMargin: `0px 0px ${Math.min(1, viewportHeight) - viewportHeight}px 0px`, threshold: 0 }) + stripObserver = observer + for (const { section } of groups) observer.observe(section) + } + }) + sizeObserver.observe(viewport) return () => { - viewport.removeEventListener('scroll', update) - window.removeEventListener('resize', update) - observer?.disconnect() - for (const { heading } of entries) heading.removeAttribute('data-stuck') + if (disposed) return + disposed = true + startObserver.disconnect() + stripObserver?.disconnect() + sizeObserver.disconnect() + for (const { heading } of groups) { + if (heading.hasAttribute('data-stuck')) heading.removeAttribute('data-stuck') + } } } diff --git a/packages/client/ui-primitives/tests/menu-group.client.spec.tsx b/packages/client/ui-primitives/tests/menu-group.client.spec.tsx index c444fb01ac..52ff44099c 100644 --- a/packages/client/ui-primitives/tests/menu-group.client.spec.tsx +++ b/packages/client/ui-primitives/tests/menu-group.client.spec.tsx @@ -1,13 +1,50 @@ // @vitest-environment jsdom /** Menu-group naming, sticky transitions, and viewport-observer ownership. */ -import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { cleanup, fireEvent, isInaccessible, render, screen, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { MenuGroup, observeStickyMenuGroups } from '../src/MenuGroup.tsx' const disposers: (() => void)[] = [] +const intersections: StubIntersectionObserver[] = [] +const sizes: StubResizeObserver[] = [] + +class StubIntersectionObserver implements IntersectionObserver { + readonly root: Element | Document | null + readonly rootMargin: string + readonly scrollMargin = '0px' + readonly thresholds: readonly number[] + observe = vi.fn<(target: Element) => void>() + unobserve = vi.fn<(target: Element) => void>() + disconnect = vi.fn<() => void>() + takeRecords = vi.fn<() => IntersectionObserverEntry[]>(() => []) + constructor(private readonly callback: IntersectionObserverCallback, options: IntersectionObserverInit = {}) { + this.root = options.root ?? null + this.rootMargin = options.rootMargin ?? '0px' + this.thresholds = typeof options.threshold === 'number' ? [options.threshold] : options.threshold ?? [0] + intersections.push(this) + } + notify(...entries: IntersectionObserverEntry[]): void { this.callback(entries, this) } +} + +class StubResizeObserver implements ResizeObserver { + observe = vi.fn<(target: Element, options?: ResizeObserverOptions) => void>() + unobserve = vi.fn<(target: Element) => void>() + disconnect = vi.fn<() => void>() + constructor(private readonly callback: ResizeObserverCallback) { sizes.push(this) } + notify(target: Element, height = 200): void { + const size: ResizeObserverSize = { blockSize: height, inlineSize: 200 } + this.callback([{ + target, contentRect: new DOMRect(0, 0, 200, height), + borderBoxSize: [size], contentBoxSize: [size], devicePixelContentBoxSize: [size], + }], this) + } +} beforeEach(() => { - vi.stubGlobal('ResizeObserver', undefined) + intersections.length = 0 + sizes.length = 0 + vi.stubGlobal('IntersectionObserver', StubIntersectionObserver) + vi.stubGlobal('ResizeObserver', StubResizeObserver) }) afterEach(() => { @@ -23,197 +60,329 @@ function observe(viewport: HTMLElement): () => void { return dispose } +function directChild(section: HTMLElement, attribute: string): HTMLElement { + const child = section.querySelector(`:scope > [${attribute}]`) + if (!child) throw new Error(`Missing ${attribute}`) + return child +} + function fixture() { - render( + const view = render(
+
, ) const viewport = screen.getByTestId('viewport') - const first = screen.getByRole('group', { name: 'Provider A' }) - const second = screen.getByRole('group', { name: 'Provider B' }) - const firstHeading = within(first).getByText('Provider A') - const secondHeading = within(second).getByText('Provider B') - const viewportRect = vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 100, 200, 200)) - const firstRect = vi.spyOn(first, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 100, 200, 100)) - const secondRect = vi.spyOn(second, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 203, 200, 100)) - return { viewport, first, second, firstHeading, secondHeading, viewportRect, firstRect, secondRect } + const groups = screen.getAllByRole('group').map(section => ({ + section, heading: directChild(section, 'data-menu-group-heading'), start: directChild(section, 'data-menu-group-start'), + })) + return { ...view, viewport, groups } +} + +function entry( + target: Element, top: number, height: number, time: number, rootHeight = 1, + rootBounds: DOMRectReadOnly | null = new DOMRect(0, 100, 200, rootHeight), +): IntersectionObserverEntry { + const bottom = top + height + const intersectionTop = Math.max(top, 100) + const intersectionBottom = Math.min(bottom, 100 + rootHeight) + const intersectionHeight = Math.max(0, intersectionBottom - intersectionTop) + return { + target, time, boundingClientRect: new DOMRect(0, top, 200, height), + rootBounds, + intersectionRect: new DOMRect(0, intersectionTop, 200, intersectionHeight), + intersectionRatio: intersectionHeight / height, isIntersecting: bottom >= 100 && top <= 100 + rootHeight, + } +} + +function startEntry(target: Element, top: number, time: number): IntersectionObserverEntry { + return entry(target, top, 1, time, 200) +} + +function startObserving(viewport: HTMLElement) { + const dispose = observe(viewport) + const start = intersections[0]! + const size = sizes[0]! + size.notify(viewport) + const strip = intersections[1]! + return { dispose, start, size, strip } } describe('MenuGroup', () => { - it('names each section with a unique direct heading and preserves child order', () => { - const { first, second, firstHeading, secondHeading } = fixture() - expect(first.tagName).toBe('SECTION') - expect(first.hasAttribute('data-menu-group')).toBe(true) - expect(firstHeading.hasAttribute('data-menu-group-heading')).toBe(true) - expect(first.getAttribute('aria-labelledby')).toBe(firstHeading.id) - expect(second.getAttribute('aria-labelledby')).toBe(secondHeading.id) - expect(firstHeading.id).not.toBe('') - expect(firstHeading.id).not.toBe(secondHeading.id) - expect(first.firstElementChild).toBe(firstHeading) - expect(firstHeading.nextElementSibling).toBe(within(first).getByRole('button', { name: 'Model A' })) + it('names each section with a unique direct heading and keeps the sentinel out of the accessible tree', () => { + const { groups: [first, second] } = fixture() + expect(first!.section.tagName).toBe('SECTION') + expect(first!.section.hasAttribute('data-menu-group')).toBe(true) + expect(first!.section.getAttribute('aria-labelledby')).toBe(first!.heading.id) + expect(second!.section.getAttribute('aria-labelledby')).toBe(second!.heading.id) + expect(first!.heading.id).not.toBe('') + expect(first!.heading.id).not.toBe(second!.heading.id) + expect(first!.start.getAttribute('aria-hidden')).toBe('true') + expect(isInaccessible(first!.start)).toBe(true) + expect(first!.start.textContent).toBe('') + expect(first!.start.hasAttribute('tabindex')).toBe(false) + expect(first!.heading.nextElementSibling).toBe(within(first!.section).getByRole('button', { name: 'Model A' })) }) it('renders a named group without rows', () => { render() const group = screen.getByRole('group', { name: 'Empty provider' }) - expect(group.children).toHaveLength(1) + expect(group.children).toHaveLength(2) expect(group.getAttribute('aria-labelledby')).toBe(within(group).getByText('Empty provider').id) }) }) describe('observeStickyMenuGroups', () => { - it('handles normal headings, sticking, handoff, and scrolling back without ResizeObserver', () => { - const { viewport, firstHeading, secondHeading, firstRect, secondRect } = fixture() - observe(viewport) - expect(firstHeading.hasAttribute('data-stuck')).toBe(false) - expect(secondHeading.hasAttribute('data-stuck')).toBe(false) - - viewport.scrollTop = 20 - firstRect.mockReturnValue(new DOMRect(0, 80, 200, 100)) - secondRect.mockReturnValue(new DOMRect(0, 183, 200, 100)) - fireEvent.scroll(viewport) - expect(firstHeading.hasAttribute('data-stuck')).toBe(true) - expect(secondHeading.hasAttribute('data-stuck')).toBe(false) - - viewport.scrollTop = 100 - firstRect.mockReturnValue(new DOMRect(0, 0, 200, 100)) - secondRect.mockReturnValue(new DOMRect(0, 103, 200, 100)) - fireEvent.scroll(viewport) - expect(firstHeading.hasAttribute('data-stuck')).toBe(false) - expect(secondHeading.hasAttribute('data-stuck')).toBe(false) - - viewport.scrollTop = 103 - secondRect.mockReturnValue(new DOMRect(0, 100, 200, 100)) - fireEvent.scroll(viewport) - expect(secondHeading.hasAttribute('data-stuck')).toBe(false) - - viewport.scrollTop = 104 - firstRect.mockReturnValue(new DOMRect(0, -4, 200, 100)) - secondRect.mockReturnValue(new DOMRect(0, 99, 200, 100)) - fireEvent.scroll(viewport) - expect(firstHeading.hasAttribute('data-stuck')).toBe(false) - expect(secondHeading.hasAttribute('data-stuck')).toBe(true) - - viewport.scrollTop = 0 - fireEvent.scroll(viewport) - expect(firstHeading.hasAttribute('data-stuck')).toBe(false) - expect(secondHeading.hasAttribute('data-stuck')).toBe(false) - }) - - it('synchronously updates an already-scrolled viewport and reads all boxes before writing attributes', () => { - const { viewport, firstHeading, secondHeading, viewportRect, firstRect, secondRect } = fixture() - viewport.scrollTop = 20 - firstRect.mockReturnValue(new DOMRect(0, 80, 200, 100)) - const firstWrite = vi.spyOn(firstHeading, 'toggleAttribute') - const secondWrite = vi.spyOn(secondHeading, 'toggleAttribute') - observe(viewport) - expect(firstHeading.hasAttribute('data-stuck')).toBe(true) - const reads = [...viewportRect.mock.invocationCallOrder, ...firstRect.mock.invocationCallOrder, ...secondRect.mock.invocationCallOrder] - const writes = [...firstWrite.mock.invocationCallOrder, ...secondWrite.mock.invocationCallOrder] - expect(Math.max(...reads)).toBeLessThan(Math.min(...writes)) - }) - - it('updates on window resize and removes scroll and resize listeners on cleanup', () => { - const { viewport, firstHeading, firstRect, viewportRect } = fixture() + it('does not synchronously read layout when observing a 30-model menu', () => { + render(
{Array.from({ length: 6 }, (_, index) => + + {Array.from({ length: 5 }, (_, row) => )} + )}
) + const viewport = screen.getByTestId('large-viewport') + const groups = screen.getAllByRole('group') + const read = vi.spyOn(Element.prototype, 'getBoundingClientRect') + const clientRects = vi.spyOn(Element.prototype, 'getClientRects') + const computedStyle = vi.spyOn(window, 'getComputedStyle') + const offsetTop = vi.spyOn(HTMLElement.prototype, 'offsetTop', 'get') + const offsetHeight = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get') + const clientHeight = vi.spyOn(Element.prototype, 'clientHeight', 'get') const addScroll = vi.spyOn(viewport, 'addEventListener') - const removeScroll = vi.spyOn(viewport, 'removeEventListener') - const removeResize = vi.spyOn(window, 'removeEventListener') + const addResize = vi.spyOn(window, 'addEventListener') const dispose = observe(viewport) - expect(addScroll).toHaveBeenCalledWith('scroll', expect.any(Function), { passive: true }) - - viewport.scrollTop = 20 - firstRect.mockReturnValue(new DOMRect(0, 80, 200, 100)) - fireEvent.resize(window) - expect(firstHeading.hasAttribute('data-stuck')).toBe(true) - viewportRect.mockReturnValue(new DOMRect(0, 70, 200, 200)) - fireEvent.resize(window) - expect(firstHeading.hasAttribute('data-stuck')).toBe(false) - viewportRect.mockReturnValue(new DOMRect(0, 100, 200, 200)) - fireEvent.resize(window) - expect(firstHeading.hasAttribute('data-stuck')).toBe(true) - - dispose() - expect(firstHeading.hasAttribute('data-stuck')).toBe(false) - expect(removeScroll).toHaveBeenCalledWith('scroll', expect.any(Function)) - expect(removeResize).toHaveBeenCalledWith('resize', expect.any(Function)) - firstRect.mockClear() + expect(read).not.toHaveBeenCalled() + expect(intersections).toHaveLength(1) + expect(sizes).toHaveLength(1) + const start = intersections[0]! + const size = sizes[0]! + expect(start.observe).toHaveBeenCalledTimes(6) + expect(size.observe.mock.calls).toEqual([[viewport]]) + size.notify(viewport) + const strip = intersections[1]! + expect(strip.observe.mock.calls).toEqual(groups.map(group => [group])) + strip.notify(...groups.map((group, index) => entry(group, 100 + index * 203, 200, 1))) + start.notify(...groups.map((group, index) => startEntry(directChild(group, 'data-menu-group-start'), 100 + index * 203, 1))) + expect(directChild(groups[0]!, 'data-menu-group-heading').hasAttribute('data-stuck')).toBe(false) + viewport.scrollTop = 0.25 fireEvent.scroll(viewport) + start.notify(startEntry(directChild(groups[0]!, 'data-menu-group-start'), 99.75, 2)) + expect(directChild(groups[0]!, 'data-menu-group-heading').hasAttribute('data-stuck')).toBe(true) fireEvent.resize(window) - expect(firstRect).not.toHaveBeenCalled() - expect(firstHeading.hasAttribute('data-stuck')).toBe(false) - }) - - it('observes the viewport and every section when ResizeObserver is available, then disconnects', () => { - const observeSize = vi.fn() - const disconnect = vi.fn() - let notify: () => void = () => { throw new Error('ResizeObserver has not been constructed') } - class StubResizeObserver implements ResizeObserver { - observe = observeSize - unobserve = vi.fn() - disconnect = disconnect - constructor(callback: ResizeObserverCallback) { - notify = () => { callback([], this) } - } - } - vi.stubGlobal('ResizeObserver', StubResizeObserver) - const { viewport, first, second, firstHeading, secondHeading, firstRect, secondRect } = fixture() - const dispose = observe(viewport) - expect(observeSize.mock.calls).toEqual([[viewport], [first], [second]]) - - viewport.scrollTop = 20 - firstRect.mockReturnValue(new DOMRect(0, 80, 200, 100)) - notify() - expect(firstHeading.hasAttribute('data-stuck')).toBe(true) - firstRect.mockReturnValue(new DOMRect(0, -20, 200, 100)) - secondRect.mockReturnValue(new DOMRect(0, 83, 200, 100)) - notify() - expect(firstHeading.hasAttribute('data-stuck')).toBe(false) - expect(secondHeading.hasAttribute('data-stuck')).toBe(true) - + size.notify(viewport, 180) + intersections[2]!.notify(entry(groups[0]!, 99.75, 200, 3)) dispose() - expect(disconnect).toHaveBeenCalledOnce() - expect(firstHeading.hasAttribute('data-stuck')).toBe(false) - expect(secondHeading.hasAttribute('data-stuck')).toBe(false) + for (const spy of [read, clientRects, computedStyle, offsetTop, offsetHeight, clientHeight]) { + expect(spy).not.toHaveBeenCalled() + } + expect(addScroll).not.toHaveBeenCalled() + expect(addResize).not.toHaveBeenCalled() }) - it('ignores nested groups, unmarked sections, and headings that are not direct children', () => { - render( -
- - - -
Indirect
-
Unmarked
-
, - ) - const viewport = screen.getByTestId('viewport') - const direct = screen.getByRole('group', { name: 'Direct' }) - const nested = screen.getByRole('group', { name: 'Nested' }) - vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 100, 200, 200)) - vi.spyOn(direct, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 80, 200, 100)) - const nestedRect = vi.spyOn(nested, 'getBoundingClientRect') + it('waits for asynchronous size and intersection delivery, including an already-scrolled viewport', () => { + const { viewport, groups: [first] } = fixture() viewport.scrollTop = 20 observe(viewport) - expect(screen.getByText('Direct').hasAttribute('data-stuck')).toBe(true) - expect(screen.getByText('Nested').hasAttribute('data-stuck')).toBe(false) - expect(screen.getByText('Indirect').hasAttribute('data-stuck')).toBe(false) - expect(screen.getByText('Unmarked').hasAttribute('data-stuck')).toBe(false) - expect(nestedRect).not.toHaveBeenCalled() + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + expect(intersections).toHaveLength(1) + intersections[0]!.notify(startEntry(first!.start, 80, 1)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + sizes[0]!.notify(viewport) + intersections[1]!.notify(entry(first!.section, 80, 100, 2)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(true) }) - it('acquires no listeners or ResizeObserver for a viewport without direct groups', () => { - render(
) + it('keeps the initial top transparent, handles fractional crossing, and clears on upward return to zero', () => { + const { viewport, groups: [first] } = fixture() + const { start, strip } = startObserving(viewport) + expect(start.root).toBe(viewport) + expect(start.thresholds).toEqual([0, 1]) + expect(strip.root).toBe(viewport) + expect(strip.rootMargin).toBe('0px 0px -199px 0px') + expect(strip.thresholds).toEqual([0]) + const write = vi.spyOn(first!.heading, 'toggleAttribute') + strip.notify(entry(first!.section, 100, 100, 1)) + start.notify(startEntry(first!.start, 100, 1)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + expect(write).not.toHaveBeenCalled() + // The section still intersects the strip, so only the sentinel crosses a threshold. + start.notify(startEntry(first!.start, 99.75, 2)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(true) + start.notify(startEntry(first!.start, 99, 3)) + expect(write).toHaveBeenCalledTimes(1) + start.notify(startEntry(first!.start, 100, 4)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + expect(write).toHaveBeenCalledTimes(2) + }) + + it('clears at the section tail and hands off only after the following group crosses the top', () => { + const { viewport, groups: [first, second] } = fixture() + const { start, strip } = startObserving(viewport) + strip.notify(entry(first!.section, 80, 100, 1), entry(second!.section, 183, 100, 1)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(true) + expect(second!.heading.hasAttribute('data-stuck')).toBe(false) + strip.notify(entry(first!.section, 0, 100, 2)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + strip.notify(entry(first!.section, -3, 100, 3), entry(second!.section, 100, 100, 3)) + expect(second!.heading.hasAttribute('data-stuck')).toBe(false) + start.notify(startEntry(second!.start, 99.75, 4)) + expect(second!.heading.hasAttribute('data-stuck')).toBe(true) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + }) + + it('finds the middle group after a fast jump even when its sentinel reports no new intersection', () => { + const { viewport, groups: [first, second, third] } = fixture() + const { start, strip } = startObserving(viewport) + start.notify(startEntry(first!.start, 100, 1), startEntry(second!.start, 503, 1), startEntry(third!.start, 906, 1)) + strip.notify(entry(first!.section, 100, 400, 1), entry(second!.section, 503, 400, 1), entry(third!.section, 906, 400, 1)) + strip.notify(entry(first!.section, -450, 400, 2), entry(second!.section, -47, 400, 2)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + expect(second!.heading.hasAttribute('data-stuck')).toBe(true) + expect(third!.heading.hasAttribute('data-stuck')).toBe(false) + }) + + it('orders samples from both observers by entry time rather than callback delivery', () => { + const { viewport, groups: [first] } = fixture() + const { start, strip } = startObserving(viewport) + start.notify(startEntry(first!.start, 99.75, 3)) + strip.notify(entry(first!.section, 100, 100, 2)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(true) + start.notify(startEntry(first!.start, 100, 1)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(true) + strip.notify(entry(first!.section, -1, 100, 5)) + strip.notify(entry(first!.section, 80, 100, 4)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + strip.notify(entry(first!.section, 80, 100, 6)) + start.notify(startEntry(first!.start, 100, 5)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(true) + }) + + it('rebuilds only the strip on viewport height changes and invalidates its old callback', () => { + const { viewport, groups: [first] } = fixture() + const { size, strip } = startObserving(viewport) + strip.notify(entry(first!.section, 80, 100, 1)) + size.notify(viewport) + size.notify(first!.section, 400) + expect(intersections).toHaveLength(2) + size.notify(viewport, 400) + expect(strip.disconnect).toHaveBeenCalledOnce() + const replacement = intersections[2]! + expect(replacement.rootMargin).toBe('0px 0px -399px 0px') + replacement.notify(entry(first!.section, 100, 100, 2)) + const write = vi.spyOn(first!.heading, 'toggleAttribute') + strip.notify(entry(first!.section, 80, 100, 100)) + expect(write).not.toHaveBeenCalled() + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + size.notify(viewport, 0.5) + expect(intersections[3]!.rootMargin).toBe('0px 0px 0px 0px') + size.notify(viewport, 0) + expect(intersections[4]!.rootMargin).toBe('0px 0px 0px 0px') + }) + + it('ignores unavailable root bounds and unrelated intersection targets', () => { + const { viewport, groups: [first] } = fixture() + const { start, strip } = startObserving(viewport) + strip.notify(entry(first!.section, 80, 100, 1)) + strip.notify(entry(first!.section, 100, 100, 2, 1, null), entry(viewport, 100, 100, 2)) + start.notify(entry(first!.start, 100, 1, 2, 200, null), startEntry(viewport, 100, 2)) + expect(first!.heading.hasAttribute('data-stuck')).toBe(true) + }) + + it('disconnects every observer, clears only stuck headings, and prevents all late writes', () => { + const { viewport, groups: [first] } = fixture() + const { dispose, start, size, strip } = startObserving(viewport) + strip.notify(entry(first!.section, 80, 100, 1)) + const remove = vi.spyOn(first!.heading, 'removeAttribute') + dispose() + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + expect(remove).toHaveBeenCalledOnce() + const write = vi.spyOn(first!.heading, 'toggleAttribute') + start.notify(startEntry(first!.start, 80, 2)) + strip.notify(entry(first!.section, 80, 100, 2)) + size.notify(viewport, 400) + dispose() + expect(intersections).toHaveLength(2) + expect(write).not.toHaveBeenCalled() + expect(remove).toHaveBeenCalledOnce() + for (const observer of [start, size, strip]) expect(observer.disconnect).toHaveBeenCalledOnce() + }) + + it('can dispose before the first size delivery without constructing a strip observer', () => { + const { viewport } = fixture() + const dispose = observe(viewport) + dispose() + sizes[0]!.notify(viewport) + expect(intersections).toHaveLength(1) + expect(sizes[0]!.disconnect).toHaveBeenCalledOnce() + }) + + it('recaptures filtered groups and rejects callbacks from the previous setup', () => { + const { viewport, rerender, groups: [first] } = fixture() + const previous = startObserving(viewport) + previous.strip.notify(entry(first!.section, 80, 100, 1)) + previous.dispose() + rerender(
) + const group = screen.getByRole('group', { name: 'Filtered' }) + const heading = directChild(group, 'data-menu-group-heading') + observe(viewport) + sizes[1]!.notify(viewport) + const current = intersections[3]! + expect(current.observe.mock.calls).toEqual([[group]]) + current.notify(entry(group, 80, 100, 2)) + expect(heading.hasAttribute('data-stuck')).toBe(true) + const write = vi.spyOn(heading, 'toggleAttribute') + previous.start.notify(startEntry(first!.start, 100, 3)) + previous.strip.notify(entry(first!.section, 100, 100, 3)) + previous.size.notify(viewport, 400) + expect(write).not.toHaveBeenCalled() + expect(heading.hasAttribute('data-stuck')).toBe(true) + expect(intersections).toHaveLength(4) + }) + + it('observes only direct groups with both direct headings and start sentinels', () => { + render(
+ +
Indirect
+
No start
+
Unmarked
+
) + const viewport = screen.getByTestId('viewport') + const group = screen.getByRole('group', { name: 'Direct' }) + const { start, strip } = startObserving(viewport) + expect(start.observe.mock.calls).toEqual([[directChild(group, 'data-menu-group-start')]]) + expect(strip.observe.mock.calls).toEqual([[group]]) + strip.notify(entry(group, 80, 100, 1)) + expect(screen.getByText('Direct').hasAttribute('data-stuck')).toBe(true) + for (const label of ['Nested', 'Indirect', 'No start', 'Unmarked']) { + expect(screen.getByText(label).hasAttribute('data-stuck')).toBe(false) + } + }) + + it('acquires no observers or listeners without valid direct groups', () => { + render(
) const viewport = screen.getByTestId('viewport') const addScroll = vi.spyOn(viewport, 'addEventListener') const addResize = vi.spyOn(window, 'addEventListener') - const ResizeObserverStub = vi.fn() - vi.stubGlobal('ResizeObserver', ResizeObserverStub) const dispose = observe(viewport) + expect(intersections).toHaveLength(0) + expect(sizes).toHaveLength(0) expect(addScroll).not.toHaveBeenCalled() expect(addResize).not.toHaveBeenCalled() - expect(ResizeObserverStub).not.toHaveBeenCalled() + expect(dispose).not.toThrow() + }) + + it.each(['IntersectionObserver', 'ResizeObserver'])('keeps CSS sticky headings transparent without %s', (api) => { + vi.stubGlobal(api, undefined) + const { viewport, groups: [first] } = fixture() + const read = vi.spyOn(Element.prototype, 'getBoundingClientRect') + const dispose = observe(viewport) + viewport.scrollTop = 20 + fireEvent.scroll(viewport) + fireEvent.resize(window) + expect(first!.heading.hasAttribute('data-stuck')).toBe(false) + expect(intersections).toHaveLength(0) + expect(sizes).toHaveLength(0) + expect(read).not.toHaveBeenCalled() expect(dispose).not.toThrow() }) }) diff --git a/tsconfig.host.json b/tsconfig.host.json index 3942b72b1b..5c5c81b7e0 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -31,6 +31,7 @@ "apps/web/tests/auto-review-denial.e2e.ts", "apps/web/tests/scaffold-generation.spec.ts", "apps/web/tests/default-model.e2e.ts", + "apps/web/tests/model-menu-observer.e2e.ts", "apps/web/tests/github-ready-review.e2e.ts", "apps/web/tests/streaming-fence-highlight.e2e.ts", "apps/web/tests/declared-reasoning.e2e.ts", From 7d3c9750e64615c0496ee2e337ccde4a8ffd3fc6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:29:18 +0800 Subject: [PATCH 20/42] test(hmr): wait for a live native watch before the first config write Chokidar reports ready once fs.watch() returns, but libuv on darwin builds the per-process FSEvents stream later on its CoreFoundation thread, so a write that lands first is never reported. The watch-config cases now close a second fs.watch directory handle after registration, which blocks until that stream is rebuilt with the remaining handles, and eventually() takes the case's effective budget instead of a local 10 s constant. Fixes #5414 --- ...-09-user-patch-hmr-test-delivery.i18n.yaml | 4 +-- ...2026-09-09-user-patch-hmr-test-delivery.md | 2 +- ...6-09-09-user-patch-hmr-test-delivery.zh.md | 2 +- packages/boot/hmr/tests/watch-config.spec.ts | 35 ++++++++++++++----- 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.i18n.yaml b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.i18n.yaml index be1357732e..138d6f46ca 100644 --- a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.i18n.yaml @@ -9,8 +9,8 @@ en: 3f8862080b8c3c89 zh: 0e83c485f02c10f1 /agent-note-user-patch-tests-control-filesystem-event-delivery/decision: - en: a49b4a6e76725b7e - zh: 9813f60c40a19f83 + en: 601a3437a4332b69 + zh: 9847646ec11d321c /agent-note-user-patch-tests-control-filesystem-event-delivery/alternatives-considered: en: 5d559ee291e2e15e zh: 828e7def587d71e8 diff --git a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md index 32ee3073dc..28fd99621a 100644 --- a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md +++ b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md @@ -12,7 +12,7 @@ The macOS Sandbox run (run 34238200206, job 102101292119) times out while waitin The [user-patch test](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) writes real patch files and delivers their add, change, and unlink events through a Chokidar watcher without native watch handles. App-boot watcher registration, refresh serialization, Include recomposition, plugin activation, failure reporting, and recovery remain real; plugin rollback is absent under the [Loader policy](../simplification/2026-09-09-nontransactional-loader.md). The fixture restores its watcher factory and disposes the Context even when setup fails before the local cleanup block. -The separate [watcher tests](../../../../packages/boot/hmr/tests/watch-config.spec.ts) own native notification delivery, including add/change/unlink, initially absent parents, and filesystem aliases. The refresh test does not establish operating-system delivery guarantees. +The separate [watcher tests](../../../../packages/boot/hmr/tests/watch-config.spec.ts) own native notification delivery, including add/change/unlink, initially absent parents, and filesystem aliases. Before their first write they close a second `fs.watch` directory handle, which on darwin blocks until libuv has rebuilt the FSEvents stream that Chokidar readiness does not wait for, and their polling deadline is the case's effective budget. The refresh test does not establish operating-system delivery guarantees. ## Alternatives considered diff --git a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md index bcd6f261c0..82fff333aa 100644 --- a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md +++ b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md @@ -12,7 +12,7 @@ macOS Sandbox 运行 (run 34238200206, job 102101292119) 在等待首次用户 p [用户 patch 测试](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) 写入真实 patch 文件,并通过不持有原生监听句柄的 Chokidar watcher 投递 add、change 和 unlink 事件。应用启动监视器注册、刷新串行化、Include 重组、插件激活、失败报告与恢复仍使用真实实现;[Loader 策略](../simplification/2026-09-09-nontransactional-loader.zh.md) 不提供插件回滚。即使初始化在进入局部清理块前失败,夹具也会恢复 watcher 工厂并销毁 Context。 -独立的 [监视器测试](../../../../packages/boot/hmr/tests/watch-config.spec.ts) 负责原生通知投递,包括 add/change/unlink、初始不存在的父目录和文件系统别名。刷新测试不验证操作系统的投递保证。 +独立的 [监视器测试](../../../../packages/boot/hmr/tests/watch-config.spec.ts) 负责原生通知投递,包括 add/change/unlink、初始不存在的父目录和文件系统别名。它们在首次写入前关闭第二个 `fs.watch` 目录句柄,该操作在 darwin 上会阻塞到 libuv 重建 Chokidar 就绪信号并不等待的 FSEvents 流为止;它们的轮询截止时间取用例的有效预算。刷新测试不验证操作系统的投递保证。 ## 考虑过的替代方案 diff --git a/packages/boot/hmr/tests/watch-config.spec.ts b/packages/boot/hmr/tests/watch-config.spec.ts index c206607145..e432bd3869 100644 --- a/packages/boot/hmr/tests/watch-config.spec.ts +++ b/packages/boot/hmr/tests/watch-config.spec.ts @@ -45,8 +45,24 @@ async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean): return ctx } -async function eventually(test: () => boolean, message: string): Promise { - const deadline = Date.now() + 10_000 +/** + * Block until this process's native directory watches deliver events. Chokidar + * reports `ready` once `fs.watch()` returns, but libuv on darwin builds the + * per-process FSEvents stream later on its CoreFoundation thread, and a write + * that lands before then is never reported. Closing a second directory handle + * waits for that thread to rebuild the stream with the remaining handles, so a + * watcher registered before this call observes the next write. Per-file handles + * use kqueue and leave the stream alone, so one call after registration covers + * the later change and unlink steps. Linux and Windows deliver from `fs.watch()` + * on; there this is an immediate open and close. + */ +function ensureNativeWatchLive(dir: string): void { + fs.watch(dir).close() +} + +/** Poll `test` every 10 ms under the calling case's effective budget. */ +async function eventually(test: () => boolean, message: string, budget: number): Promise { + const deadline = Date.now() + budget while (!test()) { if (Date.now() >= deadline) throw new Error(message) await new Promise(resolve => setTimeout(resolve, 10)) @@ -109,7 +125,7 @@ describe('HMR exact config paths', () => { } }) - it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => { + it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async ({ task }) => { const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) hmrRoots.push(dir) const filename = join(dir, 'plugins.yml') @@ -124,19 +140,20 @@ describe('HMR exact config paths', () => { observed.push('missing') } }) + ensureNativeWatchLive(dir) writeFileSync(filename, 'one', { flag: 'wx' }) - await eventually(() => observed.includes('one'), 'HMR did not observe config creation') + await eventually(() => observed.includes('one'), 'HMR did not observe config creation', task.timeout) writeFileSync(filename, 'two') - await eventually(() => observed.includes('two'), 'HMR did not observe config change') + await eventually(() => observed.includes('two'), 'HMR did not observe config change', task.timeout) unlinkSync(filename) - await eventually(() => observed.includes('missing'), 'HMR did not observe config removal') + await eventually(() => observed.includes('missing'), 'HMR did not observe config removal', task.timeout) } finally { await ctx.fiber.dispose() } }) - it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async () => { + it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async ({ task }) => { const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) hmrRoots.push(root) const dir = join(root, 'later') @@ -147,9 +164,10 @@ describe('HMR exact config paths', () => { await watchConfig(ctx, filename, {}, () => { observed.push(readFileSync(filename, 'utf8')) }) + ensureNativeWatchLive(root) mkdirSync(dir) writeFileSync(filename, 'created') - await eventually(() => observed.includes('created'), 'HMR did not observe config creation under a new parent') + await eventually(() => observed.includes('created'), 'HMR did not observe config creation under a new parent', task.timeout) } finally { await ctx.fiber.dispose() } @@ -168,6 +186,7 @@ describe('HMR exact config paths', () => { await hmr.runExclusive(() => hmr.watchConfig(filename, async () => { observed.resolve(readFileSync(filename, 'utf8')) })) + ensureNativeWatchLive(dir) writeFileSync(filename, 'created-after-transaction') expect(await observed.promise).toBe('created-after-transaction') }) From c194143fefdb0e05de8a93762b817be7520edd68 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:03:10 +0800 Subject: [PATCH 21/42] test(hmr): print pending refresh state on failure and cite the libuv barrier The case budget fires before eventually() can name the pending step, so the native-delivery cases print the observed refresh values on failure. The barrier JSDoc cites the libuv close-time wait and states that any directory handle rebuilds the per-process stream; the transaction case takes the same budget as its siblings; the hmr README records the darwin registration window as a known limitation. --- ...-09-user-patch-hmr-test-delivery.i18n.yaml | 2 +- ...6-09-09-user-patch-hmr-test-delivery.zh.md | 2 +- packages/boot/hmr/README.i18n.yaml | 4 +-- packages/boot/hmr/README.md | 1 + packages/boot/hmr/README.zh.md | 1 + packages/boot/hmr/tests/watch-config.spec.ts | 30 ++++++++++++------- 6 files changed, 26 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.i18n.yaml b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.i18n.yaml index 138d6f46ca..4eb8c76f25 100644 --- a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.i18n.yaml @@ -10,7 +10,7 @@ zh: 0e83c485f02c10f1 /agent-note-user-patch-tests-control-filesystem-event-delivery/decision: en: 601a3437a4332b69 - zh: 9847646ec11d321c + zh: 2b6bcc4636314c31 /agent-note-user-patch-tests-control-filesystem-event-delivery/alternatives-considered: en: 5d559ee291e2e15e zh: 828e7def587d71e8 diff --git a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md index 82fff333aa..3a57b6cc88 100644 --- a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md +++ b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md @@ -12,7 +12,7 @@ macOS Sandbox 运行 (run 34238200206, job 102101292119) 在等待首次用户 p [用户 patch 测试](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) 写入真实 patch 文件,并通过不持有原生监听句柄的 Chokidar watcher 投递 add、change 和 unlink 事件。应用启动监视器注册、刷新串行化、Include 重组、插件激活、失败报告与恢复仍使用真实实现;[Loader 策略](../simplification/2026-09-09-nontransactional-loader.zh.md) 不提供插件回滚。即使初始化在进入局部清理块前失败,夹具也会恢复 watcher 工厂并销毁 Context。 -独立的 [监视器测试](../../../../packages/boot/hmr/tests/watch-config.spec.ts) 负责原生通知投递,包括 add/change/unlink、初始不存在的父目录和文件系统别名。它们在首次写入前关闭第二个 `fs.watch` 目录句柄,该操作在 darwin 上会阻塞到 libuv 重建 Chokidar 就绪信号并不等待的 FSEvents 流为止;它们的轮询截止时间取用例的有效预算。刷新测试不验证操作系统的投递保证。 +独立的 [监视器测试](../../../../packages/boot/hmr/tests/watch-config.spec.ts) 负责原生通知投递,包括 add/change/unlink、初始不存在的父目录和文件系统别名。它们在首次写入前关闭第二个 `fs.watch` 目录句柄,该操作在 darwin 上会阻塞到 libuv 重建 FSEvents 流为止(Chokidar 的就绪信号并不等待该流建立);它们的轮询截止时间取用例的有效预算。刷新测试不验证操作系统的投递保证。 ## 考虑过的替代方案 diff --git a/packages/boot/hmr/README.i18n.yaml b/packages/boot/hmr/README.i18n.yaml index c5996ca9af..7e4bce6b3b 100644 --- a/packages/boot/hmr/README.i18n.yaml +++ b/packages/boot/hmr/README.i18n.yaml @@ -39,8 +39,8 @@ en: 89e0624fafd701fb zh: 19d36a33c1d9af9d /deepseek-ai-dsh-hmr/known-limitations-and-deferred-work: - en: 75e861bd7c91e37b - zh: d209c33dd5b16182 + en: 379bc2c72fd9e53e + zh: 1fff1e0e676bb161 /deepseek-ai-dsh-hmr/known-limitations-and-deferred-work/dev-note: en: 7972e4b2005ec9a4 zh: 754c49ec538c164c diff --git a/packages/boot/hmr/README.md b/packages/boot/hmr/README.md index 9e2c62c739..c4405697c6 100644 --- a/packages/boot/hmr/README.md +++ b/packages/boot/hmr/README.md @@ -87,6 +87,7 @@ Reloading a contributing plugin can change later request prefixes; HMR does not - Module replacement requires Node loader internals. Framework dependency changes call the host-provided `loader.exit()` hook; HMR itself does not restart the process. - Replacing installed package versions still requires a restart through Plugin Manager. The browser Client module graph retains its separate browser-side loading mechanism. +- `watchConfig()` resolves when Chokidar reports readiness. On darwin, libuv starts the FSEvents stream afterwards on its own thread, so a write that lands within milliseconds of registration is not reported until the next event in that directory; edits made after startup are unaffected. ### Dev Note diff --git a/packages/boot/hmr/README.zh.md b/packages/boot/hmr/README.zh.md index fa926d5998..6c82c3db8e 100644 --- a/packages/boot/hmr/README.zh.md +++ b/packages/boot/hmr/README.zh.md @@ -87,6 +87,7 @@ App-boot 负责 profile 解析和 patch 优先级规则。HMR 读取启动器提 - 模块替换需要 Node loader 内部接口。框架依赖变化调用宿主提供的 `loader.exit()` 钩子;HMR 本身不重启进程。 - 通过插件管理器替换已安装包版本仍需要重启。浏览器 Client 模块图保留独立的浏览器侧加载机制。 +- `watchConfig()` 在 Chokidar 报告就绪时 resolve。darwin 上 libuv 随后才在自己的线程启动 FSEvents 流,因此注册后数毫秒内落地的写入要等到该目录的下一个事件才会被报告;启动之后的编辑不受影响。 ### 开发备注 diff --git a/packages/boot/hmr/tests/watch-config.spec.ts b/packages/boot/hmr/tests/watch-config.spec.ts index e432bd3869..db31c0b654 100644 --- a/packages/boot/hmr/tests/watch-config.spec.ts +++ b/packages/boot/hmr/tests/watch-config.spec.ts @@ -48,13 +48,19 @@ async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean): /** * Block until this process's native directory watches deliver events. Chokidar * reports `ready` once `fs.watch()` returns, but libuv on darwin builds the - * per-process FSEvents stream later on its CoreFoundation thread, and a write - * that lands before then is never reported. Closing a second directory handle - * waits for that thread to rebuild the stream with the remaining handles, so a - * watcher registered before this call observes the next write. Per-file handles - * use kqueue and leave the stream alone, so one call after registration covers - * the later change and unlink steps. Linux and Windows deliver from `fs.watch()` - * on; there this is an immediate open and close. + * per-process FSEvents stream later on its CoreFoundation thread + * (`uv__fsevents_init` only signals that thread), and a write that lands before + * then is never reported. Closing any directory handle runs `uv__fsevents_close`, + * whose `uv_sem_wait` returns only after `uv__fsevents_reschedule` has rebuilt + * and started the stream with the remaining handles (libuv `src/unix/fsevents.c`, + * unchanged across the libuv 1.x releases bundled by Node 22–24), so a watcher + * registered before this call observes the next write. Any existing directory + * works because the stream is per process. Per-file handles use kqueue and leave + * the stream alone, so one call after registration covers the later change and + * unlink steps. Linux inotify and Windows `ReadDirectoryChangesW` are armed + * inside `fs.watch()`, so there this is an immediate open and close. Should a + * libuv release drop the close-time wait, these cases regain the darwin loss + * rate this call removes rather than failing deterministically. */ function ensureNativeWatchLive(dir: string): void { fs.watch(dir).close() @@ -125,12 +131,14 @@ describe('HMR exact config paths', () => { } }) - it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async ({ task }) => { + it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async ({ task, onTestFailed }) => { const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) hmrRoots.push(dir) const filename = join(dir, 'plugins.yml') const ctx = await bootHmr(dir) const observed: string[] = [] + // The case budget fires before eventually() can name the pending step. + onTestFailed(() => { console.error(`refresh observed ${JSON.stringify(observed)}`) }) try { await watchConfig(ctx, filename, {}, () => { try { @@ -153,13 +161,15 @@ describe('HMR exact config paths', () => { } }) - it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async ({ task }) => { + it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async ({ task, onTestFailed }) => { const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) hmrRoots.push(root) const dir = join(root, 'later') const filename = join(dir, 'plugins.yml') const ctx = await bootHmr(root) const observed: string[] = [] + // The case budget fires before eventually() can name the pending step. + onTestFailed(() => { console.error(`refresh observed ${JSON.stringify(observed)}`) }) try { await watchConfig(ctx, filename, {}, () => { observed.push(readFileSync(filename, 'utf8')) @@ -173,7 +183,7 @@ describe('HMR exact config paths', () => { } }) - it('processes native events for a watcher registered during a transaction', async () => { + it('processes native events for a watcher registered during a transaction', { timeout: 20_000 }, async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-transaction-watch-')) const filename = join(dir, 'plugins.yml') onTestFinished(() => { rmSync(dir, { recursive: true, force: true }) }) From 9126818b7fd09c18de6f97df1e5cdf518ead8406 Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Tue, 29 Sep 2026 10:25:27 +0800 Subject: [PATCH 22/42] docs: backfill v0.1.7-rc.2 upgrade guides Record the three externally perceptible breaks between dsh-v0.1.7-rc.2 and dsh-v0.2.0-rc.1: Schedule rows moving into the Automation tasks optional bundle, the legacy work-details value reading as Detailed, and Session-log uploads requiring dsh-otel with a new collector host. The template no longer asks for pull request links, which verify-repository-references rejects. --- .../skills/dsh-doc/templates/upgrade-guide.md | 2 +- .../schedule-optional-bundle/guide.md | 20 +++++++++++++ .../session-log-otel-collector/guide.md | 30 +++++++++++++++++++ .../transcript-view-legacy-normal/guide.md | 23 ++++++++++++++ 4 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 docs/upgrade-guide/v0.1.7-rc.2/schedule-optional-bundle/guide.md create mode 100644 docs/upgrade-guide/v0.1.7-rc.2/session-log-otel-collector/guide.md create mode 100644 docs/upgrade-guide/v0.1.7-rc.2/transcript-view-legacy-normal/guide.md diff --git a/.agents/skills/dsh-doc/templates/upgrade-guide.md b/.agents/skills/dsh-doc/templates/upgrade-guide.md index c5308115d3..091495b81d 100644 --- a/.agents/skills/dsh-doc/templates/upgrade-guide.md +++ b/.agents/skills/dsh-doc/templates/upgrade-guide.md @@ -18,7 +18,7 @@ description: "The externally perceptible surface that breaks and what replaces i ## Change -State the old and new behavior of the surface, who observes it, and the pull request that introduced it. +State the old and new behavior of the surface and who observes it. ## Migration diff --git a/docs/upgrade-guide/v0.1.7-rc.2/schedule-optional-bundle/guide.md b/docs/upgrade-guide/v0.1.7-rc.2/schedule-optional-bundle/guide.md new file mode 100644 index 0000000000..ccd08f683d --- /dev/null +++ b/docs/upgrade-guide/v0.1.7-rc.2/schedule-optional-bundle/guide.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "The Web composition no longer carries the Schedule rows; the Automation tasks optional bundle inserts them." +--- + +# Schedule moves into the Automation tasks optional bundle + +## Change + +In v0.1.7-rc.2, `@deepseek-ai/dsh-web-app` carried the `time-context`, `schedule`, and `ui-schedule` rows with `disabled: true`. Turning Schedule on in the Plugins page, or by hand, wrote id-targeted overrides such as `- id: schedule` with `disabled: false` into `$DSH_HOME/profiles//cordis.patch.yml` or a `--patch` overlay. + +The next release removes the three rows from the Web composition. `@deepseek-ai/dsh-experimental-schedule-bundle`, shown as Automation tasks in the Plugins page's Official group, inserts them. Every installation ships the bundle switched off. + +Profiles that enabled Schedule by id lose it after upgrading: the loader warns `patch: entry schedule not found` (and the same for `time-context` and `ui-schedule`), the `schedule_*` tools and the Automation tasks page disappear, and stored reminders stop being delivered. Stored tasks and delivery records remain on disk. + +## Migration + +1. Open Plugins, find Automation tasks in the Official group, and switch it on. The switch appends `@deepseek-ai/dsh-experimental-schedule-bundle` to `dsh.profile.bundles` in `$DSH_HOME/profiles//package.json`; for a profile edited by hand, add that entry yourself. +2. Keep existing overrides that set other fields on `schedule`, `time-context`, or `ui-schedule`, such as `deliveryHistoryDays`; they apply again once the bundle inserts the rows. Overrides that only set `disabled: false` are redundant and can be deleted. +3. Confirm: restart `dsh web`, check that startup logs no `patch: entry ... not found` warning for the three ids, and that the sidebar shows Automation tasks with the previously stored reminders. diff --git a/docs/upgrade-guide/v0.1.7-rc.2/session-log-otel-collector/guide.md b/docs/upgrade-guide/v0.1.7-rc.2/session-log-otel-collector/guide.md new file mode 100644 index 0000000000..e96eaf087f --- /dev/null +++ b/docs/upgrade-guide/v0.1.7-rc.2/session-log-otel-collector/guide.md @@ -0,0 +1,30 @@ +--- +kind: upgrade-guide +description: "Feedback Session-log uploads go to a new collector host and require the @deepseek-ai/dsh-otel service." +--- + +# Session-log uploads use the shared OTel service and a new collector + +## Change + +In v0.1.7-rc.2, `@deepseek-ai/dsh-session-telemetry-otel` owned its own OTLP transport and sent feedback-authorized Session logs to `https://harness-telemetry.deepseeksvc.com/v1/logs`. + +From the next release: + +- The plugin injects the `otel` service from the new `@deepseek-ai/dsh-otel` plugin. `@deepseek-ai/dsh-base` mounts it as the `otel` row; a composition that mounts `session-telemetry-otel` without that row leaves the plugin inactive, and feedback uploads stop. +- The default collector is `https://dsh-otel-collector.deepseeksvc.com/v1/logs`. `DSH_TELEMETRY_OTLP_URL` still overrides it. +- Uploads are split into requests of at most 4,000,000 uncompressed bytes (`maxRequestBytes`). + +Profiles that extend `@deepseek-ai/dsh-base` and networks with unrestricted egress need no action. Persisted Session formats are unchanged. + +## Migration + +1. If a custom composition lists `session-telemetry-otel` without `@deepseek-ai/dsh-base`, add the row before it: + + ```yaml + - id: otel + name: '@deepseek-ai/dsh-otel' + ``` + +2. If an egress allowlist or proxy rule names `harness-telemetry.deepseeksvc.com`, add `dsh-otel-collector.deepseeksvc.com`. A deployment that points `DSH_TELEMETRY_OTLP_URL` at its own collector keeps its setting. +3. Confirm: send feedback with Session-log upload enabled and check that the configured collector receives the request; for a self-hosted collector, check its receive log. diff --git a/docs/upgrade-guide/v0.1.7-rc.2/transcript-view-legacy-normal/guide.md b/docs/upgrade-guide/v0.1.7-rc.2/transcript-view-legacy-normal/guide.md new file mode 100644 index 0000000000..b0af748cad --- /dev/null +++ b/docs/upgrade-guide/v0.1.7-rc.2/transcript-view-legacy-normal/guide.md @@ -0,0 +1,23 @@ +--- +kind: upgrade-guide +description: "The legacy ui-chat.transcriptView value normal, and an unset value in non-Desktop Web, now display Detailed instead of Standard." +--- + +# Legacy `normal` work details display as Detailed + +## Change + +The Host setting `ui-chat.transcriptView` selects Settings → General → Work details. In v0.1.7-rc.2 the client displayed the legacy saved value `normal` as `standard`, and every client used `standard` when the setting was missing, `null`, or invalid. + +From the next release: + +- A saved `normal` displays as `detailed` on Desktop and Web. The saved value on disk is not rewritten. +- A missing, `null`, or invalid value displays as `detailed` in non-Desktop Web (the npm `dsh web` install). Desktop still uses `standard`. +- Saved `compact`, `standard`, `detailed`, and `verbose` values are unchanged. + +Users who relied on the old reading see running Turns with process-group bodies expanded instead of collapsed summaries. + +## Migration + +1. To keep the previous presentation, open Settings → General → Work details and choose Standard. The client saves `standard` to `ui-chat.transcriptView`, which every later default change leaves untouched. +2. Confirm: start a Turn that calls tools and check that its process group shows a collapsed summary with live task detail. From 11925ce3453a4711784246c0f284bb3d4dbdad9d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:29:28 +0800 Subject: [PATCH 23/42] fix(web): stabilize e2e and settings placement wait for SVG changed-file card update performance settings placement skip crashing WebKit model-menu observer case --- apps/web/tests/expected/desktop-locale/automatic.expected.md | 4 ++-- apps/web/tests/expected/desktop-locale/selected.expected.md | 4 ++-- apps/web/tests/expected/settings-chrome/dialog-en.expected.md | 4 ++-- .../expected/settings-chrome/dialog-no-browser.expected.md | 4 ++-- apps/web/tests/expected/settings-chrome/dialog.expected.md | 4 ++-- apps/web/tests/model-menu-observer.e2e.ts | 3 ++- apps/web/tests/present-svg.e2e.ts | 2 ++ packages/client/ui-chat/README.i18n.yaml | 4 ++-- packages/client/ui-chat/README.md | 2 +- packages/client/ui-chat/README.zh.md | 2 +- packages/client/ui-chat/src/client/apply.ts | 2 +- 11 files changed, 19 insertions(+), 16 deletions(-) diff --git a/apps/web/tests/expected/desktop-locale/automatic.expected.md b/apps/web/tests/expected/desktop-locale/automatic.expected.md index 77b4593b90..9bc10efc51 100644 --- a/apps/web/tests/expected/desktop-locale/automatic.expected.md +++ b/apps/web/tests/expected/desktop-locale/automatic.expected.md @@ -27,8 +27,8 @@ - button "编辑快捷键" - text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为 - button "排队发送" - - text: 在使用官方模型 API 时上传 Session Log 帮助改进 DeepSeek 模型与产品 - - switch "在使用官方模型 API 时上传 Session Log" - text: 性能与用量 选择性能与用量信息展示的详细程度 - button "详细" + - text: 在使用官方模型 API 时上传 Session Log 帮助改进 DeepSeek 模型与产品 + - switch "在使用官方模型 API 时上传 Session Log" - text: 当前版本:{{version}} diff --git a/apps/web/tests/expected/desktop-locale/selected.expected.md b/apps/web/tests/expected/desktop-locale/selected.expected.md index c4fd5e5eff..7d839e4a25 100644 --- a/apps/web/tests/expected/desktop-locale/selected.expected.md +++ b/apps/web/tests/expected/desktop-locale/selected.expected.md @@ -27,8 +27,8 @@ - button "Edit shortcuts" - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior - button "Queue" - - text: Upload Session Log when using the official model API Help improve DeepSeek models and products. - - switch "Upload Session Log when using the official model API" - text: Performance & usage Choose how much performance and usage information to show - button "Detailed" + - text: Upload Session Log when using the official model API Help improve DeepSeek models and products. + - switch "Upload Session Log when using the official model API" - text: "Current version: {{version}}" diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md index ad3348ea5a..0883c044fb 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -27,8 +27,8 @@ - button "Edit shortcuts" - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior - button "Queue" - - text: Upload Session Log when using the official model API Help improve DeepSeek models and products. - - switch "Upload Session Log when using the official model API" - text: Performance & usage Choose how much performance and usage information to show - button "Detailed" + - text: Upload Session Log when using the official model API Help improve DeepSeek models and products. + - switch "Upload Session Log when using the official model API" - text: "Current version: {{version}}" diff --git a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md index ad3348ea5a..0883c044fb 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md @@ -27,8 +27,8 @@ - button "Edit shortcuts" - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior - button "Queue" - - text: Upload Session Log when using the official model API Help improve DeepSeek models and products. - - switch "Upload Session Log when using the official model API" - text: Performance & usage Choose how much performance and usage information to show - button "Detailed" + - text: Upload Session Log when using the official model API Help improve DeepSeek models and products. + - switch "Upload Session Log when using the official model API" - text: "Current version: {{version}}" diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index 0f17fa643f..562592ab7d 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -27,8 +27,8 @@ - button "编辑快捷键" - text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为 - button "排队发送" - - text: 在使用官方模型 API 时上传 Session Log 帮助改进 DeepSeek 模型与产品 - - switch "在使用官方模型 API 时上传 Session Log" - text: 性能与用量 选择性能与用量信息展示的详细程度 - button "详细" + - text: 在使用官方模型 API 时上传 Session Log 帮助改进 DeepSeek 模型与产品 + - switch "在使用官方模型 API 时上传 Session Log" - text: 当前版本:{{version}} diff --git a/apps/web/tests/model-menu-observer.e2e.ts b/apps/web/tests/model-menu-observer.e2e.ts index 5da1f9e2f2..4c58bc9db9 100644 --- a/apps/web/tests/model-menu-observer.e2e.ts +++ b/apps/web/tests/model-menu-observer.e2e.ts @@ -141,7 +141,8 @@ describe.skipIf(webSnapshotMode() === 'record').each([ } }) - it('keeps both pickers ordered, sticky and keyboard-operable without synchronous group rect reads', async () => { + // FIXME: WebKit 26.5 crashes while filtering the grouped model rows in this scenario. + it.skipIf(engine === webkit)('keeps both pickers ordered, sticky and keyboard-operable without synchronous group rect reads', async () => { onTestFailed(() => saveFailureShot(page, `web-e2e-model-menu-observer-${engine.name()}`)) let buttonMaterial: Awaited> | undefined for (const entry of ['button', 'command'] as const) { diff --git a/apps/web/tests/present-svg.e2e.ts b/apps/web/tests/present-svg.e2e.ts index cd44f0ec67..c275f73c7a 100644 --- a/apps/web/tests/present-svg.e2e.ts +++ b/apps/web/tests/present-svg.e2e.ts @@ -122,6 +122,8 @@ describe('web e2e: requested SVG is explicitly delivered', () => { expect(await page.getByText('产物', { exact: true }).count()).toBe(0) // The scaffold workspace is not a git repository; the write call supplies its single changed file. const changes = page.locator('[data-changed-files]') + // Changed-file cards wait for a separate summary request after the presentation events arrive. + await changes.waitFor({ state: 'visible' }) expect(await changes.count()).toBe(1) expect(await changes.getByText(`已编辑 ${FILE}`, { exact: true }).count()).toBe(1) expect(await changes.getByRole('list').count()).toBe(0) diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 4d0cfda86a..264a09cf05 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -24,8 +24,8 @@ en: 2157fcd6b0d100b5 zh: 9e865ac3953ebe30 /deepseek-ai-dsh-client-ui-chat/turn-token-usage: - en: e1dd055d68ab25ea - zh: c5b62a22c9e457cb + en: 11436f75c377ed1b + zh: 42707b8efc201dad /deepseek-ai-dsh-client-ui-chat/completed-turn-footer: en: b4cd36e7ab91281e zh: 8ccf412b9dde61d5 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index ddbd9c7c6e..58428e7773 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -58,7 +58,7 @@ Generic command rows retain the ordinary command glyph in every lifecycle state; A completed Turn shows an expandable usage row only when the loaded window includes `turn/start` and every started model attempt reports safe, exact usage. The row omits unavailable optional buckets. Incomplete or contradictory accounting hides the complete disclosure instead of presenting a partial total. -Settings → General → Performance & usage appears immediately before Current version and stores `ui-chat.performanceUsage` as `detailed` (default) or `compact`. Compact shows only available output speed and cache-hit percentage beneath the composer, without interactive statistic dialogs or per-Turn usage. Detailed exposes session statistics and per-Turn token usage. Neither mode shows elapsed time in the completed-turn footer. The preference changes presentation only; accounting and Session events remain intact. +Settings → General → Performance & usage appears after Send behavior while busy and before Session Log upload, and stores `ui-chat.performanceUsage` as `detailed` (default) or `compact`. Compact shows only available output speed and cache-hit percentage beneath the composer, without interactive statistic dialogs or per-Turn usage. Detailed exposes session statistics and per-Turn token usage. Neither mode shows elapsed time in the completed-turn footer. The preference changes presentation only; accounting and Session events remain intact. On non-loopback browsers, the preference remains process-local because the settings scope cannot persist writes. Explicit selections update every consumer immediately; accepted Host settings reconcile the live value on loopback browsers. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 490a44f6d8..28455ece27 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -58,7 +58,7 @@ Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node, 只有当已加载窗口包含 `turn/start`,且每次已启动的模型尝试都报告安全、精确的用量时,已完成轮次才显示可展开的用量行。该行会省略不可用的可选用量桶。记账不完整或相互矛盾时,整个详情都不显示,避免把部分总量冒充完整结果。 -设置 → 通用设置 → 性能与用量位于当前版本之前,将 `ui-chat.performanceUsage` 保存为 `detailed`(默认)或 `compact`。简洁模式仅在输入框下方显示可用的输出速度和缓存命中率,不显示统计交互卡片或每轮用量。详细模式提供会话统计和每轮 token 用量。两种模式的已完成轮次页脚均不显示耗时。该偏好仅影响展示,记账和 Session 事件保持完整。 +设置 → 通用设置 → 性能与用量位于繁忙时的发送行为之后、Session Log 上传之前,将 `ui-chat.performanceUsage` 保存为 `detailed`(默认)或 `compact`。简洁模式仅在输入框下方显示可用的输出速度和缓存命中率,不显示统计交互卡片或每轮用量。详细模式提供会话统计和每轮 token 用量。两种模式的已完成轮次页脚均不显示耗时。该偏好仅影响展示,记账和 Session 事件保持完整。 在非回环地址浏览器中,设置作用域无法持久化写入,因此该偏好仅在当前进程内生效。明确选择会立即更新所有使用方;回环地址浏览器收到 Host 已接受的设置后会同步当前值。 diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 9608c2b74a..e5807726ac 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -156,7 +156,7 @@ export function apply(ctx: Context): void { ctx.slots.inject('settings.general.item', () => ctx.slots.register({ name: 'settings.general.item', id: 'performance-usage', - order: 95, + order: 30, locale: NS, inject: (): PerformanceUsageRowInjected => ({ hooks: { performanceUsage }, From 61d4ab0b6ccedd954cc1d00e8643d0f275b24fde Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Tue, 29 Sep 2026 10:34:29 +0800 Subject: [PATCH 24/42] docs: make upgrade guides bilingual and drop the OTel guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade guides now pair guide.md with guide.zh.md and guide.i18n.yaml under the ordinary translation-pairing gate. verify-upgrade-guides checks the Chinese sibling's metadata and its 变更/迁移 sections; the 500-word ceiling applies to the English source. Remove the session-log-otel-collector guide. --- .../skills/dsh-create-upgrade-guide/SKILL.md | 9 +++--- .agents/skills/dsh-doc/SKILL.md | 2 +- .../dsh-doc/references/metadata-links-i18n.md | 4 +-- .../skills/dsh-doc/templates/upgrade-guide.md | 4 ++- docs/i18n/README.i18n.yaml | 4 +-- docs/i18n/README.md | 1 - docs/i18n/README.zh.md | 1 - .../schedule-optional-bundle/guide.i18n.yaml | 16 ++++++++++ .../schedule-optional-bundle/guide.md | 2 ++ .../schedule-optional-bundle/guide.zh.md | 22 ++++++++++++++ .../session-log-otel-collector/guide.md | 30 ------------------- .../guide.i18n.yaml | 16 ++++++++++ .../transcript-view-legacy-normal/guide.md | 2 ++ .../transcript-view-legacy-normal/guide.zh.md | 25 ++++++++++++++++ scripts/translation-pairing.manifest.json | 3 +- scripts/verify-upgrade-guides.spec.ts | 30 +++++++++++++++---- scripts/verify-upgrade-guides.ts | 25 ++++++++++------ 17 files changed, 137 insertions(+), 59 deletions(-) create mode 100644 docs/upgrade-guide/v0.1.7-rc.2/schedule-optional-bundle/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.1.7-rc.2/schedule-optional-bundle/guide.zh.md delete mode 100644 docs/upgrade-guide/v0.1.7-rc.2/session-log-otel-collector/guide.md create mode 100644 docs/upgrade-guide/v0.1.7-rc.2/transcript-view-legacy-normal/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.1.7-rc.2/transcript-view-legacy-normal/guide.zh.md diff --git a/.agents/skills/dsh-create-upgrade-guide/SKILL.md b/.agents/skills/dsh-create-upgrade-guide/SKILL.md index cf3b46bfdd..9723537e22 100644 --- a/.agents/skills/dsh-create-upgrade-guide/SKILL.md +++ b/.agents/skills/dsh-create-upgrade-guide/SKILL.md @@ -25,7 +25,7 @@ Pre-stable internal APIs whose consumers are all updated in the same change need - `` is the `version` field of the root `package.json` when you write the guide, for example `v0.1.7-rc.2`. The guide describes the upgrade from that release to the next one. - `` is a kebab-case name of the changed surface, for example `profile-flag-rename`. -- No other files belong in the tree: no index, no Chinese counterpart, no attachments. +- Each item directory holds exactly `guide.md`, its Chinese counterpart `guide.zh.md`, and the pairing record `guide.i18n.yaml`: no index, no attachments. ## Maintenance @@ -36,18 +36,19 @@ While the root version is unchanged, the current version directory describes the Start from the [upgrade-guide template](../dsh-doc/templates/upgrade-guide.md). `verify-upgrade-guides` enforces: - frontmatter with exactly `kind: upgrade-guide` and a one-sentence `description` naming what breaks; add a key only together with its gate check, then backfill every guide; -- one `#` title, then exactly the `## Change` and `## Migration` sections, in that order; -- at most 500 words, frontmatter included. +- one `#` title, then exactly the `## Change` and `## Migration` sections in `guide.md`, and `## 变更` and `## 迁移` in `guide.zh.md`, in that order; +- at most 500 words in `guide.md`, frontmatter included. `Change` states the old and new behavior of the surface and who is affected. `Migration` lists ordered steps that name exact files, keys, commands, or symbols, and states how to confirm the migration worked. When a guide needs more than 500 words, the extra text belongs elsewhere: link source files or symbols instead of restating code, move rationale into an [Agent Note](../../notes/README.md), and turn repeated mechanical steps into a script or `dsh` command that the guide invokes. -Write English only, following [dsh-prose-standard](../dsh-prose-standard/SKILL.md). +Follow [dsh-prose-standard](../dsh-prose-standard/SKILL.md). Write the Chinese counterpart in the same change under the [bilingual pairing rules](../../../docs/AGENTS.md), then record the pair with `pnpm run verify-translation-pairing --write docs/upgrade-guide/v//guide.md`. ## Verify ```sh pnpm run verify-upgrade-guides +pnpm run verify-translation-pairing docs/upgrade-guide/v//guide.md pnpm run verify-md-links ``` diff --git a/.agents/skills/dsh-doc/SKILL.md b/.agents/skills/dsh-doc/SKILL.md index 0c97714672..5e32d4c07e 100644 --- a/.agents/skills/dsh-doc/SKILL.md +++ b/.agents/skills/dsh-doc/SKILL.md @@ -57,7 +57,7 @@ The `kind` frontmatter field selects exactly one README template. Every kind in - `persistence-change` → [templates/persistence-change.md](templates/persistence-change.md): a dated record in `docs/persistence-changes/` — acknowledge a detected type transition with per-root predecessors and generated after schemas. - `persistence-release` → [templates/persistence-release.md](templates/persistence-release.md): a pinned tag in `docs/persistence-changes/releases/` — compare reconstructed historical types without claiming compatibility acknowledgement. - `persistence-format` → [templates/persistence-format.md](templates/persistence-format.md): a historical Session format checkpoint in `docs/persistence-changes/historical-formats/`, with complete schemas; the current writer uses the existing catalog. -- `upgrade-guide` → [templates/upgrade-guide.md](templates/upgrade-guide.md): an English-only `docs/upgrade-guide/v//guide.md` — migration steps for one externally perceptible break, owned by [dsh-create-upgrade-guide](../dsh-create-upgrade-guide/SKILL.md). +- `upgrade-guide` → [templates/upgrade-guide.md](templates/upgrade-guide.md): `docs/upgrade-guide/v//guide.md` and its Chinese sibling — migration steps for one externally perceptible break, owned by [dsh-create-upgrade-guide](../dsh-create-upgrade-guide/SKILL.md). Open the template before writing and follow its skeleton and rules; it states what the kind is, how the page is structured, and the fact checks each section owes. Add a new kind only together with a distinct template file, a documented repository position or declared owner, and a focused check that maps documents to it. diff --git a/.agents/skills/dsh-doc/references/metadata-links-i18n.md b/.agents/skills/dsh-doc/references/metadata-links-i18n.md index 34e4cc55bb..4ea476085c 100644 --- a/.agents/skills/dsh-doc/references/metadata-links-i18n.md +++ b/.agents/skills/dsh-doc/references/metadata-links-i18n.md @@ -29,7 +29,7 @@ kind: "package-reference" ## The kind system -`kind` selects the document template directly; every kind maps to exactly one template that exists in this skill, and no template exists without a kind. Files named `docs/persistence-changes/YYYY-MM-DD-slug.md` use `persistence-change`; files named `docs/persistence-changes/releases/dsh-v*.md` use `persistence-release`. Files named `docs/persistence-changes/historical-formats/vN.md` use `persistence-format`. Their Chinese siblings use the same kind. Files named `docs/upgrade-guide/v//guide.md` use `upgrade-guide` and have no Chinese sibling. Folder READMEs are indexes, not records. Derive package README kinds mechanically, in this order: +`kind` selects the document template directly; every kind maps to exactly one template that exists in this skill, and no template exists without a kind. Files named `docs/persistence-changes/YYYY-MM-DD-slug.md` use `persistence-change`; files named `docs/persistence-changes/releases/dsh-v*.md` use `persistence-release`. Files named `docs/persistence-changes/historical-formats/vN.md` use `persistence-format`. Their Chinese siblings use the same kind. Files named `docs/upgrade-guide/v//guide.md` and their `guide.zh.md` siblings use `upgrade-guide`. Folder READMEs are indexes, not records. Derive package README kinds mechanically, in this order: 1. The README is `packages/README.md` or `packages//README.md` → `package-group`. 2. The package manifest declares `dsh.bundle.patch` → `package-bundle`. @@ -45,7 +45,7 @@ kind: "package-reference" | `persistence-change` | `docs/persistence-changes/YYYY-MM-DD-slug.md` and its Chinese sibling | [persistence-change.md](../templates/persistence-change.md) | Historical acknowledgement: mechanically detected type changes, per-root predecessor references, compatibility decision, and generated after schemas. | | `persistence-release` | `docs/persistence-changes/releases/dsh-v*.md` and its Chinese sibling | [persistence-release.md](../templates/persistence-release.md) | Retrospective observation: pinned tag, adjacent before/after digests, actual version constants, and changed after schemas; no compatibility acknowledgement. | | `persistence-format` | `docs/persistence-changes/historical-formats/vN.md` and its Chinese sibling | [persistence-format.md](../templates/persistence-format.md) | Selected historical format: source evidence, complete root schemas and reachable types; the current writer uses the generated catalog. | -| `upgrade-guide` | `docs/upgrade-guide/v//guide.md`, English only | [upgrade-guide.md](../templates/upgrade-guide.md) | Upgrade path: one externally perceptible break from the directory's release to the next, with `Change` and `Migration` sections in at most 500 words. | +| `upgrade-guide` | `docs/upgrade-guide/v//guide.md` and its Chinese sibling | [upgrade-guide.md](../templates/upgrade-guide.md) | Upgrade path: one externally perceptible break from the directory's release to the next, with `Change` and `Migration` sections in at most 500 English words. | Before assigning `package-library` or `package-bundle`, inspect the facts: read `package.json` for `dsh.bundle.patch` and `src/index.ts` for the entry shape (`apply` export or a default service export is a plugin; a plain module API is a library). `dsh plugin --profile add ` installs any npm dependency, but the profile reconcile activates a layer only for a package that declares `dsh.bundle`; never present that command as an install path for a library or a plain plugin. The documentation check derives the expected kind from these same facts, rejects another value, and rejects `name`, `audience`, `tags`, and README-local `i18n` metadata. Add a new kind only with a distinct template, an unambiguous repository position or declared owner, and a focused check that maps documents to it. diff --git a/.agents/skills/dsh-doc/templates/upgrade-guide.md b/.agents/skills/dsh-doc/templates/upgrade-guide.md index 091495b81d..d47f7dc4ae 100644 --- a/.agents/skills/dsh-doc/templates/upgrade-guide.md +++ b/.agents/skills/dsh-doc/templates/upgrade-guide.md @@ -1,6 +1,6 @@ # Template: upgrade-guide -Use this kind for `docs/upgrade-guide/v//guide.md`. The guides are English-only and have no folder index. [dsh-create-upgrade-guide](../../dsh-create-upgrade-guide/SKILL.md) owns scope, placement, maintenance, and length rules; `pnpm run verify-upgrade-guides` enforces them. +Use this kind for `docs/upgrade-guide/v//guide.md` and its Chinese sibling `guide.zh.md`, whose sections are `## 变更` and `## 迁移`. The tree has no folder index. [dsh-create-upgrade-guide](../../dsh-create-upgrade-guide/SKILL.md) owns scope, placement, maintenance, and length rules; `pnpm run verify-upgrade-guides` enforces them. ## Frontmatter @@ -16,6 +16,8 @@ description: "The externally perceptible surface that breaks and what replaces i ````markdown # +English | [中文](guide.zh.md) + ## Change State the old and new behavior of the surface and who observes it. diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index ddb3521a91..4c794f49bd 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/README.i18n.yaml @@ -12,8 +12,8 @@ en: 08386e96eebf06d9 zh: c6d2b41199b0c466 /bilingual-documentation/scope-and-exclusions: - en: 093bd091c9d46eda - zh: ab6cd07f94c740e4 + en: db70e758813d339b + zh: 3dcea283a08c47cb /bilingual-documentation/division-of-labor: en: 88097c97cb72f3ba zh: 78a18000626cc818 diff --git a/docs/i18n/README.md b/docs/i18n/README.md index ca970d6b2d..4c23fac67e 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -60,7 +60,6 @@ Generated English sources omit the language switcher that ordinary authored sour - `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction. - [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior. - [review-ownership/README.md](../../.github/review-ownership/README.md) — repository-internal approval policy maintained in English only. -- `docs/upgrade-guide/` — agent-facing upgrade guides maintained in English only. - `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them. **Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class. diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index edc6f972fc..a905a0bdae 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -62,7 +62,6 @@ - `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。 - [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。 - [review-ownership/README.md](../../.github/review-ownership/README.md):仓库内部审批策略,只以英文维护。 -- `docs/upgrade-guide/`:面向 agent 的升级指南,只以英文维护。 - `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。 **统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。 diff --git a/docs/upgrade-guide/v0.1.7-rc.2/schedule-optional-bundle/guide.i18n.yaml b/docs/upgrade-guide/v0.1.7-rc.2/schedule-optional-bundle/guide.i18n.yaml new file mode 100644 index 0000000000..f0046f5fc9 --- /dev/null +++ b/docs/upgrade-guide/v0.1.7-rc.2/schedule-optional-bundle/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.1.7-rc.2/schedule-optional-bundle/guide.md +/: + en: 224d57a22f38ba26 + zh: 1472851dde2bf9c3 +/schedule-moves-into-the-automation-tasks-optional-bundle: + en: 138f715e3a260eb6 + zh: eff22703b2c79915 +/schedule-moves-into-the-automation-tasks-optional-bundle/change: + en: b1b250e677c23b10 + zh: b71c1591ea4f9ce5 +/schedule-moves-into-the-automation-tasks-optional-bundle/migration: + en: 656fea5dc45f8310 + zh: 8dfe16d2fdacf71a diff --git a/docs/upgrade-guide/v0.1.7-rc.2/schedule-optional-bundle/guide.md b/docs/upgrade-guide/v0.1.7-rc.2/schedule-optional-bundle/guide.md index ccd08f683d..72ffb9f689 100644 --- a/docs/upgrade-guide/v0.1.7-rc.2/schedule-optional-bundle/guide.md +++ b/docs/upgrade-guide/v0.1.7-rc.2/schedule-optional-bundle/guide.md @@ -5,6 +5,8 @@ description: "The Web composition no longer carries the Schedule rows; the Autom # Schedule moves into the Automation tasks optional bundle +English | [中文](guide.zh.md) + ## Change In v0.1.7-rc.2, `@deepseek-ai/dsh-web-app` carried the `time-context`, `schedule`, and `ui-schedule` rows with `disabled: true`. Turning Schedule on in the Plugins page, or by hand, wrote id-targeted overrides such as `- id: schedule` with `disabled: false` into `$DSH_HOME/profiles//cordis.patch.yml` or a `--patch` overlay. diff --git a/docs/upgrade-guide/v0.1.7-rc.2/schedule-optional-bundle/guide.zh.md b/docs/upgrade-guide/v0.1.7-rc.2/schedule-optional-bundle/guide.zh.md new file mode 100644 index 0000000000..46483a1175 --- /dev/null +++ b/docs/upgrade-guide/v0.1.7-rc.2/schedule-optional-bundle/guide.zh.md @@ -0,0 +1,22 @@ +--- +kind: upgrade-guide +description: "Web 组合不再包含 Schedule 的三行;改由“自动化任务”可选 bundle 插入。" +--- + +# Schedule 移入“自动化任务”可选 bundle + +[English](guide.md) | 中文 + +## 变更 + +在 v0.1.7-rc.2 中,`@deepseek-ai/dsh-web-app` 以 `disabled: true` 包含 `time-context`、`schedule` 和 `ui-schedule` 三行。在插件管理页或手动开启 Schedule,会向 `$DSH_HOME/profiles//cordis.patch.yml` 或 `--patch` overlay 写入按 id 定位的覆盖项,例如 `- id: schedule` 加 `disabled: false`。 + +下一版本从 Web 组合中移除这三行。插件管理页“官方”分组中的“自动化任务”,即 `@deepseek-ai/dsh-experimental-schedule-bundle`,负责插入它们。每个安装都自带该 bundle,默认关闭。 + +按 id 开启 Schedule 的 profile 升级后会失去 Schedule:loader 警告 `patch: entry schedule not found`(`time-context` 和 `ui-schedule` 同理),`schedule_*` 工具和自动化任务页面消失,已存储的提醒不再投递。已存储的任务和投递记录仍保留在磁盘上。 + +## 迁移 + +1. 打开插件管理页,在“官方”分组中找到“自动化任务”并开启。开关会把 `@deepseek-ai/dsh-experimental-schedule-bundle` 追加到 `$DSH_HOME/profiles//package.json` 的 `dsh.profile.bundles`;手动维护的 profile 需自行添加该条目。 +2. 保留为 `schedule`、`time-context` 或 `ui-schedule` 设置其他字段的覆盖项,例如 `deliveryHistoryDays`;bundle 插入这些行后它们重新生效。只设置 `disabled: false` 的覆盖项已无作用,可以删除。 +3. 确认:重启 `dsh web`,检查启动日志中这三个 id 不再出现 `patch: entry ... not found` 警告,且侧栏显示“自动化任务”并列出之前存储的提醒。 diff --git a/docs/upgrade-guide/v0.1.7-rc.2/session-log-otel-collector/guide.md b/docs/upgrade-guide/v0.1.7-rc.2/session-log-otel-collector/guide.md deleted file mode 100644 index e96eaf087f..0000000000 --- a/docs/upgrade-guide/v0.1.7-rc.2/session-log-otel-collector/guide.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -kind: upgrade-guide -description: "Feedback Session-log uploads go to a new collector host and require the @deepseek-ai/dsh-otel service." ---- - -# Session-log uploads use the shared OTel service and a new collector - -## Change - -In v0.1.7-rc.2, `@deepseek-ai/dsh-session-telemetry-otel` owned its own OTLP transport and sent feedback-authorized Session logs to `https://harness-telemetry.deepseeksvc.com/v1/logs`. - -From the next release: - -- The plugin injects the `otel` service from the new `@deepseek-ai/dsh-otel` plugin. `@deepseek-ai/dsh-base` mounts it as the `otel` row; a composition that mounts `session-telemetry-otel` without that row leaves the plugin inactive, and feedback uploads stop. -- The default collector is `https://dsh-otel-collector.deepseeksvc.com/v1/logs`. `DSH_TELEMETRY_OTLP_URL` still overrides it. -- Uploads are split into requests of at most 4,000,000 uncompressed bytes (`maxRequestBytes`). - -Profiles that extend `@deepseek-ai/dsh-base` and networks with unrestricted egress need no action. Persisted Session formats are unchanged. - -## Migration - -1. If a custom composition lists `session-telemetry-otel` without `@deepseek-ai/dsh-base`, add the row before it: - - ```yaml - - id: otel - name: '@deepseek-ai/dsh-otel' - ``` - -2. If an egress allowlist or proxy rule names `harness-telemetry.deepseeksvc.com`, add `dsh-otel-collector.deepseeksvc.com`. A deployment that points `DSH_TELEMETRY_OTLP_URL` at its own collector keeps its setting. -3. Confirm: send feedback with Session-log upload enabled and check that the configured collector receives the request; for a self-hosted collector, check its receive log. diff --git a/docs/upgrade-guide/v0.1.7-rc.2/transcript-view-legacy-normal/guide.i18n.yaml b/docs/upgrade-guide/v0.1.7-rc.2/transcript-view-legacy-normal/guide.i18n.yaml new file mode 100644 index 0000000000..89d7925446 --- /dev/null +++ b/docs/upgrade-guide/v0.1.7-rc.2/transcript-view-legacy-normal/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.1.7-rc.2/transcript-view-legacy-normal/guide.md +/: + en: 7d7e527ed3c87db1 + zh: 4654dab85b66bcb0 +/legacy-normal-work-details-display-as-detailed: + en: 3c88b0b06094c162 + zh: 9ae8ff49975824a9 +/legacy-normal-work-details-display-as-detailed/change: + en: 0e2287aa768141f3 + zh: 035992a94129fd9e +/legacy-normal-work-details-display-as-detailed/migration: + en: 6078ccce1ceacfd6 + zh: 6a394cf5259c9965 diff --git a/docs/upgrade-guide/v0.1.7-rc.2/transcript-view-legacy-normal/guide.md b/docs/upgrade-guide/v0.1.7-rc.2/transcript-view-legacy-normal/guide.md index b0af748cad..cfb2be2793 100644 --- a/docs/upgrade-guide/v0.1.7-rc.2/transcript-view-legacy-normal/guide.md +++ b/docs/upgrade-guide/v0.1.7-rc.2/transcript-view-legacy-normal/guide.md @@ -5,6 +5,8 @@ description: "The legacy ui-chat.transcriptView value normal, and an unset value # Legacy `normal` work details display as Detailed +English | [中文](guide.zh.md) + ## Change The Host setting `ui-chat.transcriptView` selects Settings → General → Work details. In v0.1.7-rc.2 the client displayed the legacy saved value `normal` as `standard`, and every client used `standard` when the setting was missing, `null`, or invalid. diff --git a/docs/upgrade-guide/v0.1.7-rc.2/transcript-view-legacy-normal/guide.zh.md b/docs/upgrade-guide/v0.1.7-rc.2/transcript-view-legacy-normal/guide.zh.md new file mode 100644 index 0000000000..4bcc77d18e --- /dev/null +++ b/docs/upgrade-guide/v0.1.7-rc.2/transcript-view-legacy-normal/guide.zh.md @@ -0,0 +1,25 @@ +--- +kind: upgrade-guide +description: "旧值 ui-chat.transcriptView: normal,以及非 Desktop Web 中未设置的值,改为按“详细”而非“标准”展示。" +--- + +# 旧值 `normal` 的工作步骤展示改为“详细” + +[English](guide.md) | 中文 + +## 变更 + +Host 设置 `ui-chat.transcriptView` 对应设置 → 通用设置 → 工作步骤展示。在 v0.1.7-rc.2 中,客户端把旧保存值 `normal` 按 `standard` 展示;设置缺失、为 `null` 或无效时,所有客户端都使用 `standard`。 + +从下一版本起: + +- 保存的 `normal` 在 Desktop 和 Web 中均按 `detailed` 展示,磁盘中的保存值不会被改写。 +- 设置缺失、为 `null` 或无效时,非 Desktop Web(npm 安装的 `dsh web`)按 `detailed` 展示;Desktop 仍使用 `standard`。 +- 已保存的 `compact`、`standard`、`detailed` 和 `verbose` 保持不变。 + +依赖旧读取方式的用户会看到运行中轮次的过程组正文直接展开,而不是收起的摘要。 + +## 迁移 + +1. 如需保留原有展示,打开设置 → 通用设置 → 工作步骤展示并选择“标准”。客户端会把 `standard` 保存到 `ui-chat.transcriptView`,此后的默认值变化不影响该值。 +2. 确认:发起一个调用工具的轮次,检查其过程组显示收起的摘要和实时任务详情。 diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index fe517aa995..f9bb1549a2 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -8,7 +8,6 @@ "docs/cordis-api/inherited.md", "docs/i18n/style-samples.md", "docs/i18n/terminology.md", - "docs/i18n/translation-prompt.md", - "docs/upgrade-guide/" + "docs/i18n/translation-prompt.md" ] } diff --git a/scripts/verify-upgrade-guides.spec.ts b/scripts/verify-upgrade-guides.spec.ts index 07b1e516aa..6d92157e8a 100644 --- a/scripts/verify-upgrade-guides.spec.ts +++ b/scripts/verify-upgrade-guides.spec.ts @@ -34,6 +34,11 @@ const VALID = [ '', ].join('\n') +const VALID_ZH = VALID + .replace('description: "The `--preset` flag is removed."', 'description: "移除 `--preset` 参数。"') + .replace('## Change', '## 变更') + .replace('## Migration', '## 迁移') + function fixtureRoot(files: Record): string { const root = mkdtempSync(join(tmpdir(), 'dsh-upgrade-guides-')) roots.push(root) @@ -46,30 +51,32 @@ function fixtureRoot(files: Record): string { } describe('upgrade guide gate', () => { - it('accepts an absent tree and conforming guides, including prerelease versions and CRLF files', () => { + it('accepts an absent tree and conforming guide pairs, including prerelease versions and CRLF files', () => { expect(collectUpgradeGuideViolations(fixtureRoot({}))).toEqual([]) expect(collectUpgradeGuideViolations(fixtureRoot({ 'v0.1.7-rc.2/profile-flag/guide.md': VALID, + 'v0.1.7-rc.2/profile-flag/guide.zh.md': VALID_ZH, + 'v0.1.7-rc.2/profile-flag/guide.i18n.yaml': 'not parsed by this gate\n', 'v1.0.0/crlf-item/guide.md': VALID.replaceAll('\n', '\r\n'), }))).toEqual([]) }) - it('rejects files outside the version/item/guide.md layout', () => { + it('rejects files outside the version/item/guide pair layout', () => { expect(collectUpgradeGuideViolations(fixtureRoot({ 'README.md': VALID, '0.1.7/item/guide.md': VALID, 'v0.1/item/guide.md': VALID, 'v0.1.7/Item_Name/guide.md': VALID, - 'v0.1.7/item/guide.zh.md': VALID, + 'v0.1.7/item/notes.md': VALID, 'v0.1.7/item/extra/guide.md': VALID, }))).toEqual([ 'docs/upgrade-guide/0.1.7/item/guide.md', 'docs/upgrade-guide/README.md', 'docs/upgrade-guide/v0.1.7/Item_Name/guide.md', 'docs/upgrade-guide/v0.1.7/item/extra/guide.md', - 'docs/upgrade-guide/v0.1.7/item/guide.zh.md', + 'docs/upgrade-guide/v0.1.7/item/notes.md', 'docs/upgrade-guide/v0.1/item/guide.md', - ].map(path => `${path}: only v//guide.md files belong in docs/upgrade-guide`)) + ].map(path => `${path}: only v//guide.{md,zh.md,i18n.yaml} files belong in docs/upgrade-guide`)) }) it('rejects missing, extra, or invalid frontmatter fields', () => { @@ -102,11 +109,22 @@ describe('upgrade guide gate', () => { expect(violations[0]).toBe('docs/upgrade-guide/v0.1.7/extra/guide.md: headings must be one "#" title followed by "## Change", "## Migration"; got "# `--profile` replaces `--preset`", "## Change", "## Migration", "## References"') }) - it('rejects a guide over 500 words, counting frontmatter', () => { + it('checks the Chinese sibling against its own section names and metadata', () => { + expect(collectUpgradeGuideViolations(fixtureRoot({ + 'v0.1.7/english-headings/guide.zh.md': VALID, + 'v0.1.7/wrong-kind/guide.zh.md': VALID_ZH.replace('kind: upgrade-guide', 'kind: guide'), + }))).toEqual([ + 'docs/upgrade-guide/v0.1.7/english-headings/guide.zh.md: headings must be one "#" title followed by "## 变更", "## 迁移"; got "# `--profile` replaces `--preset`", "## Change", "## Migration"', + 'docs/upgrade-guide/v0.1.7/wrong-kind/guide.zh.md: kind must be upgrade-guide', + ]) + }) + + it('rejects an English guide over 500 words, counting frontmatter, and leaves the Chinese sibling uncounted', () => { const words = VALID.split(/\s+/u).filter(Boolean).length expect(collectUpgradeGuideViolations(fixtureRoot({ 'v0.1.7/at-limit/guide.md': `${VALID}${'word '.repeat(500 - words)}\n`, 'v0.1.7/over-limit/guide.md': `${VALID}${'word '.repeat(501 - words)}\n`, + 'v0.1.7/over-limit/guide.zh.md': `${VALID_ZH}${'词 '.repeat(600)}\n`, }))).toEqual(['docs/upgrade-guide/v0.1.7/over-limit/guide.md: 501 words exceeds the 500-word ceiling']) }) }) diff --git a/scripts/verify-upgrade-guides.ts b/scripts/verify-upgrade-guides.ts index 9a776013af..bb067cb41f 100644 --- a/scripts/verify-upgrade-guides.ts +++ b/scripts/verify-upgrade-guides.ts @@ -1,6 +1,7 @@ /** * Enforce the `docs/upgrade-guide/v//guide.md` layout, metadata, * sections, and word ceiling defined by the `dsh-create-upgrade-guide` skill. + * `verify-translation-pairing` owns the Chinese sibling's pairing record. * @module scripts/verify-upgrade-guides */ @@ -11,17 +12,20 @@ import { fromMarkdown } from 'mdast-util-from-markdown' const ROOT = resolve(import.meta.dirname, '..') const GUIDE_ROOT = 'docs/upgrade-guide' -const MAX_WORDS = 500 const FRONTMATTER_KEYS = ['description', 'kind'] -const SECTIONS = ['Change', 'Migration'] +/** Required `##` sections for each guide language; the word ceiling applies to the English source. */ +const LANGUAGES = { + 'guide.md': { sections: ['Change', 'Migration'], maxWords: 500 }, + 'guide.zh.md': { sections: ['变更', '迁移'], maxWords: undefined }, +} as const const SEMVER = String.raw`(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?` -const GUIDE_PATH = new RegExp(`^v${SEMVER}/[a-z0-9]+(?:-[a-z0-9]+)*/guide\\.md$`, 'u') +const GUIDE_PATH = new RegExp(`^v${SEMVER}/[a-z0-9]+(?:-[a-z0-9]+)*/(guide\\.md|guide\\.zh\\.md|guide\\.i18n\\.yaml)$`, 'u') /** Return one guide's frontmatter and structure violations. */ -function guideViolations(source: string): string[] { +function guideViolations(source: string, language: typeof LANGUAGES[keyof typeof LANGUAGES]): string[] { const violations: string[] = [] const words = source.split(/\s+/u).filter(Boolean).length - if (words > MAX_WORDS) violations.push(`${String(words)} words exceeds the ${String(MAX_WORDS)}-word ceiling`) + if (language.maxWords !== undefined && words > language.maxWords) violations.push(`${String(words)} words exceeds the ${String(language.maxWords)}-word ceiling`) const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/u.exec(source) if (frontmatter === null) return [...violations, 'must start with YAML frontmatter'] @@ -36,7 +40,7 @@ function guideViolations(source: string): string[] { const body = source.slice(frontmatter[0].length) const outline = fromMarkdown(body).children .flatMap(node => node.type === 'heading' && node.depth <= 2 ? [body.slice(node.position?.start.offset, node.position?.end.offset)] : []) - const expected = SECTIONS.map(section => `## ${section}`) + const expected = language.sections.map(section => `## ${section}`) if (outline.length !== expected.length + 1 || !outline[0]?.startsWith('# ') || outline.slice(1).join('\n') !== expected.join('\n')) { violations.push(`headings must be one "#" title followed by ${expected.map(heading => `"${heading}"`).join(', ')}; got ${outline.map(heading => `"${heading}"`).join(', ') || 'none'}`) } @@ -58,11 +62,14 @@ export function collectUpgradeGuideViolations(root: string): string[] { .sort() for (const file of files) { const path = `${GUIDE_ROOT}/${file}` - if (!GUIDE_PATH.test(file)) { - violations.push(`${path}: only v//guide.md files belong in ${GUIDE_ROOT}`) + const name = GUIDE_PATH.exec(file)?.[1] + if (name === undefined) { + violations.push(`${path}: only v//guide.{md,zh.md,i18n.yaml} files belong in ${GUIDE_ROOT}`) continue } - for (const violation of guideViolations(readFileSync(join(directory, file), 'utf8'))) violations.push(`${path}: ${violation}`) + if (name === 'guide.md' || name === 'guide.zh.md') { + for (const violation of guideViolations(readFileSync(join(directory, file), 'utf8'), LANGUAGES[name])) violations.push(`${path}: ${violation}`) + } } return violations } From cfa7784fd491ef16e815daa0d975414cffe44eaa Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 29 Sep 2026 10:35:06 +0800 Subject: [PATCH 25/42] refactor(sidebar-right): declare SidebarRightHost where the binding was Moves the interface to the position SidebarRightBinding held, after the controller factory, so the change reads as one interface replacing the other. Its content is unchanged. --- .../ui-sidebar-right/src/client/service.ts | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/client/ui-sidebar-right/src/client/service.ts b/packages/client/ui-sidebar-right/src/client/service.ts index f056f4b280..c4bfc1252b 100644 --- a/packages/client/ui-sidebar-right/src/client/service.ts +++ b/packages/client/ui-sidebar-right/src/client/service.ts @@ -79,28 +79,6 @@ interface SidebarRightSessions { readonly openTabs: SidebarTabInventory['source'] } -/** What the controller needs from the page around it: the viewport rule and focus continuity. */ -export interface SidebarRightHost { - /** - * Whether the viewport is narrow enough that an expanded panel is presented fullscreen. - * @returns the rule's verdict for the frame width the seats last rendered at. - */ - readonly autoFullscreen: () => boolean - /** - * Commit a page operation and focus the pane it selects. - * @param sessionId - the Session whose page is opening. - * @param open - the synchronous operation; returns the selected pane, or `undefined` when unchanged. - */ - readonly openWithFocus: (sessionId: SessionId, open: () => PaneId | undefined) => void - /** - * Commit a keyboard/menu close and retain focus on a surviving visible pane. - * @param sessionId - the Session whose page is closing. - * @param paneId - the pane whose page is closing. - * @param close - the synchronous cleanup and removal. - */ - readonly closeWithFocus: (sessionId: SessionId, paneId: PaneId, close: () => void) => void -} - /** * Create the public controller and the plugin-private Session callbacks. * Adoption reconciles restored records before any seat renders, then follows commits. @@ -147,6 +125,28 @@ export function createSidebarRightController(tabs: SidebarRightTabRegistry, pin: } } +/** What the controller needs from the page around it: the viewport rule and focus continuity. */ +export interface SidebarRightHost { + /** + * Whether the viewport is narrow enough that an expanded panel is presented fullscreen. + * @returns the rule's verdict for the frame width the seats last rendered at. + */ + readonly autoFullscreen: () => boolean + /** + * Commit a page operation and focus the pane it selects. + * @param sessionId - the Session whose page is opening. + * @param open - the synchronous operation; returns the selected pane, or `undefined` when unchanged. + */ + readonly openWithFocus: (sessionId: SessionId, open: () => PaneId | undefined) => void + /** + * Commit a keyboard/menu close and retain focus on a surviving visible pane. + * @param sessionId - the Session whose page is closing. + * @param paneId - the pane whose page is closing. + * @param close - the synchronous cleanup and removal. + */ + readonly closeWithFocus: (sessionId: SessionId, paneId: PaneId, close: () => void) => void +} + /** Where an open lands; every field is optional and the defaults are the common case. */ export interface SidebarRightPlacement { /** Land a new tab in this pane instead of the active docked one. */ From 41021fc80307e571ca1694f2cc2b456c6be33bcf Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 29 Sep 2026 10:40:17 +0800 Subject: [PATCH 26/42] fix(web): list each shell once in the new-terminal menu Shell discovery keyed entries by resolved path. On merged-/usr hosts the login shell /bin/bash from $SHELL and the PATH hit /usr/bin/bash for the `bash` candidate are one file with two spellings, so the menu showed "bash" twice. Key discovered shells by executable name, compared without letter case or `.exe`, and keep the earliest entry so the default shell stays first. --- .../api/terminal-controller/README.i18n.yaml | 4 ++-- packages/api/terminal-controller/README.md | 2 +- packages/api/terminal-controller/README.zh.md | 2 +- .../api/terminal-controller/src/shells.ts | 22 +++++++++++++------ .../tests/controller.spec.ts | 10 +++++++++ 5 files changed, 29 insertions(+), 11 deletions(-) diff --git a/packages/api/terminal-controller/README.i18n.yaml b/packages/api/terminal-controller/README.i18n.yaml index 019535b653..545139127a 100644 --- a/packages/api/terminal-controller/README.i18n.yaml +++ b/packages/api/terminal-controller/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 1fa9a72d1ee045cc /deepseek-ai-dsh-api-terminal-controller/use-this-package: - en: 234403af4f95ea67 - zh: d711c2e62d7d37f8 + en: 0148cabb3a856226 + zh: 059a60413474a173 /deepseek-ai-dsh-api-terminal-controller/understand-the-implementation: en: 94dcd1835c55acc2 zh: cbfeb2da8cb60395 diff --git a/packages/api/terminal-controller/README.md b/packages/api/terminal-controller/README.md index dd93237a94..f33ce680c0 100644 --- a/packages/api/terminal-controller/README.md +++ b/packages/api/terminal-controller/README.md @@ -27,7 +27,7 @@ Open the execution environment's default shell in a Session workspace from the W The Web bundle mounts this package with the subprocess provider, sandbox policy and Typert Gateway. Sandbox policy supplies only the fallback working directory for Sessions without a cwd. `remote.terminal` exposes `environment`, `shells`, `list`, `create`, `retain`, `follow`, `write`, `resize`, `rename` and `close`; each operation is scoped by Session identity. Listing reads retained Host terminals directly, so viewing an offline Session neither activates an Agent nor produces a recovery error. -Shell discovery lists the execution environment's declared default shell first. Only when the provider omits that default does resolution use `/bin/sh` on POSIX or `cmd.exe` on Windows. An optional `shell` profile overrides that choice with executable `path`, display `name` and `args` (default `[]`). The selector also probes `shellCandidates` through the execution provider and omits only confirmed lookup misses. Creation accepts a discovered `shellPath` and verifies it again; resolution or transport failure is reported without launching a different shell. Environment lookup returns the working directory and limits without resolving a shell, so an unavailable default does not prevent reattaching to an existing process. Automatic POSIX profiles start interactively, and PowerShell uses `-NoLogo`, so completion and startup configuration remain shell-owned. The Session workspace supplies the initial directory. User terminals run with the execution environment’s system-user permissions, independently of the Agent’s sandbox mode and approval policy. Operating-system and container restrictions still apply; DSH does not elevate the user. The subprocess provider retains its credential-environment scrubbing. +Shell discovery lists the execution environment's declared default shell first. Only when the provider omits that default does resolution use `/bin/sh` on POSIX or `cmd.exe` on Windows. An optional `shell` profile overrides that choice with executable `path`, display `name` and `args` (default `[]`). The selector also probes `shellCandidates` through the execution provider and omits confirmed lookup misses. It lists each executable name once, ignoring letter case and `.exe`, and keeps the earliest entry, because PATH lookup can reach the default through another directory, such as `/usr/bin/bash` for `/bin/bash` on merged-`/usr` systems. Creation accepts a discovered `shellPath` and verifies it again; resolution or transport failure is reported without launching a different shell. Environment lookup returns the working directory and limits without resolving a shell, so an unavailable default does not prevent reattaching to an existing process. Automatic POSIX profiles start interactively, and PowerShell uses `-NoLogo`, so completion and startup configuration remain shell-owned. The Session workspace supplies the initial directory. User terminals run with the execution environment’s system-user permissions, independently of the Agent’s sandbox mode and approval policy. Operating-system and container restrictions still apply; DSH does not elevate the user. The subprocess provider retains its credential-environment scrubbing. | Configuration | Default | Meaning | |---|---|---| diff --git a/packages/api/terminal-controller/README.zh.md b/packages/api/terminal-controller/README.zh.md index b3bda5f404..d4d92f0d3e 100644 --- a/packages/api/terminal-controller/README.zh.md +++ b/packages/api/terminal-controller/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" Web bundle 将此包与 subprocess provider、sandbox policy 和 Typert Gateway 一起挂载。Sandbox policy 仅为没有 cwd 的 Session 提供默认工作目录。`remote.terminal` 提供 `environment`、`shells`、`list`、`create`、`retain`、`follow`、`write`、`resize`、`rename` 和 `close`;每个操作均按 Session 标识限定范围。列表直接读取 Host 保留的终端,因此查看离线 Session 不会激活 Agent,也不会产生恢复错误。 -Shell 探测结果首先列出执行环境声明的默认 shell。仅当 provider 未声明默认值时,才在 POSIX 使用 `/bin/sh`,在 Windows 使用 `cmd.exe`。可选的 `shell` profile 通过可执行路径 `path`、显示名称 `name` 和参数 `args`(默认 `[]`)覆盖这一选择。选择器还会通过执行 provider 探测 `shellCandidates`,仅省略确定未找到的候选。创建请求接受探测返回的 `shellPath` 并再次验证;解析或传输失败会直接报告,不启动其他 shell。环境查询只返回工作目录和限制,不解析 shell,因此默认 shell 不可用时仍可重新连接已有进程。POSIX 自动 profile 以交互模式启动,PowerShell 使用 `-NoLogo`,补全和启动配置仍由 shell 提供。初始目录来自 Session 工作区。用户终端使用执行环境中系统用户的权限,独立于 Agent 的沙箱模式和审批策略。操作系统和容器的限制仍然生效;DSH 不提升用户权限。Subprocess provider 继续清除环境中的凭据变量。 +Shell 探测结果首先列出执行环境声明的默认 shell。仅当 provider 未声明默认值时,才在 POSIX 使用 `/bin/sh`,在 Windows 使用 `cmd.exe`。可选的 `shell` profile 通过可执行路径 `path`、显示名称 `name` 和参数 `args`(默认 `[]`)覆盖这一选择。选择器还会通过执行 provider 探测 `shellCandidates`,并省略确定未找到的候选。同一可执行文件名(不区分大小写,忽略 `.exe`)只列出最早的一项,因为 PATH 查找可能经由其他目录找到默认 shell,例如 merged-`/usr` 系统上 `/bin/bash` 对应的 `/usr/bin/bash`。创建请求接受探测返回的 `shellPath` 并再次验证;解析或传输失败会直接报告,不启动其他 shell。环境查询只返回工作目录和限制,不解析 shell,因此默认 shell 不可用时仍可重新连接已有进程。POSIX 自动 profile 以交互模式启动,PowerShell 使用 `-NoLogo`,补全和启动配置仍由 shell 提供。初始目录来自 Session 工作区。用户终端使用执行环境中系统用户的权限,独立于 Agent 的沙箱模式和审批策略。操作系统和容器的限制仍然生效;DSH 不提升用户权限。Subprocess provider 继续清除环境中的凭据变量。 | 配置 | 默认值 | 含义 | |---|---|---| diff --git a/packages/api/terminal-controller/src/shells.ts b/packages/api/terminal-controller/src/shells.ts index c8010c38c4..7c1f6a4ce2 100644 --- a/packages/api/terminal-controller/src/shells.ts +++ b/packages/api/terminal-controller/src/shells.ts @@ -22,18 +22,26 @@ export async function resolveShell( } function profile(path: string): TerminalShell { - const name = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) - const kind = name.toLowerCase().replace(/\.exe$/u, '') - return { path, name, args: kind === 'cmd' ? [] : kind === 'pwsh' || kind === 'powershell' ? ['-NoLogo'] : ['-i'] } + const kind = shellKind(path) + return { path, name: executableName(path), args: kind === 'cmd' ? [] : kind === 'pwsh' || kind === 'powershell' ? ['-NoLogo'] : ['-i'] } +} + +function executableName(path: string): string { + return path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) +} + +function shellKind(path: string): string { + return executableName(path).toLowerCase().replace(/\.exe$/u, '') } /** - * List verified candidates after the configured or environment-default shell. + * List verified candidates after the configured or environment-default shell, one per executable name ignoring case and `.exe`. + * PATH lookup can reach the default through another directory, such as `/usr/bin/bash` for `/bin/bash` on merged-`/usr` systems. * @param subprocess - target execution provider. * @param configured - optional default profile. * @param candidates - executable names or paths permitted for shell selection. * @param signal - discovery cancellation. - * @returns unique installed shells, with the default first; transport failures reject. + * @returns installed shells with the default first, keeping the earliest entry per name; transport failures reject. */ export async function discoverShells( subprocess: SubprocessRuntime, configured: TerminalShell | undefined, @@ -50,8 +58,8 @@ export async function discoverShells( const shells = new Map() for (const shell of [preferred, ...found]) { if (shell === undefined) continue - const key = shell.path.includes('\\') ? shell.path.toLowerCase() : shell.path - if (!shells.has(key)) shells.set(key, shell) + const kind = shellKind(shell.path) + if (!shells.has(kind)) shells.set(kind, shell) } return [...shells.values()] } diff --git a/packages/api/terminal-controller/tests/controller.spec.ts b/packages/api/terminal-controller/tests/controller.spec.ts index 3d7f4434d1..a516965228 100644 --- a/packages/api/terminal-controller/tests/controller.spec.ts +++ b/packages/api/terminal-controller/tests/controller.spec.ts @@ -546,6 +546,16 @@ it('deduplicates Windows executable paths regardless of letter case', async () = expect(await h.controller.shells(h.agent, signal())).toEqual([{ path: 'C:\\Windows\\cmd.exe', name: 'Command Prompt', args: [] }]) }) +it('lists each executable name once when PATH reaches the default shell through another directory', async () => { + const h = fixture({ shell: undefined, shellCandidates: ['zsh', 'bash'] }) + // Merged /usr: the login shell is /bin/bash, while PATH lists /usr/bin before the /bin link. + h.subprocess.resolveExecutable.mockImplementation(async path => path.startsWith('/') ? path : `/usr/bin/${path}`) + expect(await h.controller.shells(h.agent, signal())).toEqual([ + { path: '/bin/bash', name: 'bash', args: ['-i'] }, + { path: '/usr/bin/zsh', name: 'zsh', args: ['-i'] }, + ]) +}) + it('retains a known terminal without Agent resolution and fences stream admission after explicit close', async () => { const h = fixture() await h.controller.create(h.agent, request, signal()) From 8671d305831e7508a69d8445ad25240acd4e8c06 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:00:58 +0800 Subject: [PATCH 27/42] =?UTF-8?q?fix(web):=20=E6=97=A0=E8=B5=A0=E9=87=91?= =?UTF-8?q?=E6=97=B6=E9=9A=90=E8=97=8F=E8=AE=BE=E7=BD=AE=E9=A1=B5=E8=B5=A0?= =?UTF-8?q?=E9=87=91=E8=A1=8C=E5=B9=B6=E4=BF=AE=E6=AD=A3=E5=BC=95=E5=AF=BC?= =?UTF-8?q?=E5=85=85=E5=80=BC=E6=96=87=E6=A1=88=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 余额读取成功但没有正额赠金时,账号设置页不再显示赠金余额行及其分隔线; 加载中与读取失败时仍保留该行。删除不再使用的 bonusEmpty 文案。 引导页跳过充值的英文提示改为指向 Settings → Accounts。 --- packages/client/ui-settings-account/README.i18n.yaml | 4 ++-- packages/client/ui-settings-account/README.md | 2 +- packages/client/ui-settings-account/README.zh.md | 2 +- .../ui-settings-account/src/client/AccountSection.tsx | 6 ++---- packages/client/ui-settings-account/src/client/locales.ts | 2 -- .../ui-settings-account/src/client/locales/onboarding.ts | 2 +- .../ui-settings-account/tests/account.client.spec.tsx | 6 ++---- .../ui-settings-account/tests/expected/details-en.txt | 2 +- .../ui-settings-account/tests/expected/details-zh.txt | 2 +- 9 files changed, 11 insertions(+), 17 deletions(-) diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index 5aba3a01d9..1b597f4d27 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/README.i18n.yaml @@ -24,8 +24,8 @@ en: 6b3ad3b6860100cf zh: aeced4fb37642f15 /deepseek-ai-dsh-client-ui-settings-account/further-exploration: - en: c785923662681191 - zh: f24389c25e954035 + en: a28acdef21b1643a + zh: 5e2e357a6b716274 /deepseek-ai-dsh-client-ui-settings-account/model-experience: en: ee1df7ca3467ee3f zh: 84904a5148da3862 diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index 75f37f6789..18604436f7 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -77,7 +77,7 @@ Account login uses a dismissible dialog before the model onboarding credential e A terminal account-state stream failure appears in the sign-in dialog or Account settings. Plugin unload suppresses late failure reports. -The balance card shows recharge funds and bonus credit in separate rows. The bonus row is present while signed in; without positive bonus credit it states that no bonus is available. A failed wallet read shows the existing unavailable copy as a link to Platform — the same destination and behavior as the Usage action, so the embedded page opens on Desktop and a new tab elsewhere — while the loading and empty states stay plain text. Currencies retain their own amounts, and a notice date is rendered only inside the server’s own message. +The balance card shows recharge funds and bonus credit in separate rows. While signed in, the bonus row appears during loading, after a failed wallet read, and when a wallet holds positive bonus credit; a successful read without positive bonus credit omits the row. A failed wallet read shows the existing unavailable copy as a link to Platform — the same destination and behavior as the Usage action, so the embedded page opens on Desktop and a new tab elsewhere — while the loading state stays plain text. Currencies retain their own amounts, and a notice date is rendered only inside the server’s own message. A successful login selects the first available `deepseek-account` model as the default when no other provider has a configured API key, even if a previous default was saved. Configured keys preserve the existing default even when their provider has no available models. Session-specific selections remain unchanged. diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index 1b7b184587..ccc6daaeae 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -77,7 +77,7 @@ Desktop preload 标记在账号凭证已存储后启用引导。进度属于本 账户状态流的终止错误会显示在登录对话框或账户设置中。插件卸载后不再报告延迟到达的错误。 -余额卡片分行展示充值余额与赠金余额。赠金行在登录态下常驻;没有正额赠金时显示暂无可用赠金。余额或赠金读取失败时,沿用既有的不可用文案并改为指向 Platform 的链接,目标与行为同“查询用量”一致:Desktop 打开内嵌页面,其它环境新开标签页;加载中与暂无赠金仍为纯文本。不同币种分别显示;日期只在服务端自己的消息中出现。 +余额卡片分行展示充值余额与赠金余额。登录态下,赠金行在加载中、余额读取失败以及存在正额赠金时显示;读取成功但没有正额赠金时不显示该行。余额或赠金读取失败时,沿用既有的不可用文案并改为指向 Platform 的链接,目标与行为同“查询用量”一致:Desktop 打开内嵌页面,其它环境新开标签页;加载中仍为纯文本。不同币种分别显示;日期只在服务端自己的消息中出现。 登录成功时,若其他提供方均未配置 API key,则将 `deepseek-account` 的第一个可用模型设为默认值,即使此前保存过默认选择。其他提供方只要配置了 key,就保留原默认值,即使其没有可用模型。会话内的显式选择保持不变。 diff --git a/packages/client/ui-settings-account/src/client/AccountSection.tsx b/packages/client/ui-settings-account/src/client/AccountSection.tsx index d59b45a976..953b48cc53 100644 --- a/packages/client/ui-settings-account/src/client/AccountSection.tsx +++ b/packages/client/ui-settings-account/src/client/AccountSection.tsx @@ -183,7 +183,7 @@ export function AccountSection({ t, useAccount, useTheme, start, cancel, openPla ? {t(!signedIn ? 'balanceSignedOut' : 'loading')} : platformLink(t('balanceUnavailable'), css.unavailableLink)} - {signedIn && <> + {signedIn && (details?.balance?.status !== 'ready' || bonusWallets.length > 0) && <>
{t('bonusBalance')} @@ -194,9 +194,7 @@ export function AccountSection({ t, useAccount, useTheme, start, cancel, openPla )} : details?.balance === undefined ? {t('loading')} - : details.balance.status === 'failed' - ? platformLink(t('balanceUnavailable'), css.unavailableLink) - : {t('bonusEmpty')}} + : platformLink(t('balanceUnavailable'), css.unavailableLink)}
} diff --git a/packages/client/ui-settings-account/src/client/locales.ts b/packages/client/ui-settings-account/src/client/locales.ts index 9b6178bf38..e6f743a4a5 100644 --- a/packages/client/ui-settings-account/src/client/locales.ts +++ b/packages/client/ui-settings-account/src/client/locales.ts @@ -33,7 +33,6 @@ export const en = { quotaDescription: 'DeepSeek Harness cannot start a new task with this account when no balance is available. Would you like to top up? You can also top up later in Settings → Account.', quotaTopUp: 'Top up', bonusNoticeTitle: 'Bonus credited', - bonusEmpty: 'No bonus available', } as const /** Account locale keys. */ export type AccountKey = keyof typeof en @@ -69,5 +68,4 @@ export const zh: Record = { quotaDescription: '没有可用额度时,DeepSeek Harness 无法开始新的任务,是否前往充值?或者可以稍后前往 设置 → 账号与余额 进行充值。', quotaTopUp: '去充值', bonusNoticeTitle: '赠金已到账', - bonusEmpty: '暂无可用赠金', } diff --git a/packages/client/ui-settings-account/src/client/locales/onboarding.ts b/packages/client/ui-settings-account/src/client/locales/onboarding.ts index 5e3b71cf20..d3b1e22be8 100644 --- a/packages/client/ui-settings-account/src/client/locales/onboarding.ts +++ b/packages/client/ui-settings-account/src/client/locales/onboarding.ts @@ -76,7 +76,7 @@ export const onboardingEnglishCopy: Record onboardingSkipDescription: 'You can change how progress, performance, and usage are displayed, or enable Coding Tools, anytime in Profile → General Settings.', onboardingKeepSetting: 'Continue setup', onboardingNoCreditTitle: 'Skip adding credits?', - onboardingNoCreditDescription: 'DeepSeek Harness can’t start new tasks without credits. You can add them later in Profile → Accounts.', + onboardingNoCreditDescription: 'DeepSeek Harness can’t start new tasks without credits. You can add them later in Settings → Accounts.', onboardingUnderstood: 'Got it', onboardingGoTopUp: 'Add credits', onboardingSaveFailed: 'Could not save settings. Try again.', diff --git a/packages/client/ui-settings-account/tests/account.client.spec.tsx b/packages/client/ui-settings-account/tests/account.client.spec.tsx index 5294b9be22..577e213b5a 100644 --- a/packages/client/ui-settings-account/tests/account.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/account.client.spec.tsx @@ -446,13 +446,11 @@ it.each([en, zh])('renders positive bonus wallets separately from recharge balan }) it.each([[], [{ currency: 'CNY' as const, balance: '0.00' }, { currency: 'USD' as const, balance: '-1.00' }]].map(bonusWallets => ({ bonusWallets })))( - 'keeps the bonus row without inventing credit for zero or negative wallets', ({ bonusWallets }) => { + 'omits the bonus row for zero or negative wallets', ({ bonusWallets }) => { mount({ status: 'credential-stored', attempt: null }, en, { balance: { status: 'ready', value: [{ currency: 'CNY', balance: '0' }], bonusWallets }, }) - // The row itself stays and states the absence. - expect(screen.getByText(en.bonusBalance)).toBeTruthy() - expect(screen.getByText(en.bonusEmpty)).toBeTruthy() + expect(screen.queryByText(en.bonusBalance)).toBeNull() expect(screen.getByText('¥0.00')).toBeTruthy() expect(screen.queryByText('¥-1.00')).toBeNull() }, diff --git a/packages/client/ui-settings-account/tests/expected/details-en.txt b/packages/client/ui-settings-account/tests/expected/details-en.txt index da68efdf2b..08cdd9dbe9 100644 --- a/packages/client/ui-settings-account/tests/expected/details-en.txt +++ b/packages/client/ui-settings-account/tests/expected/details-en.txt @@ -1 +1 @@ -Harness Mock (TEST ONLY)138****0000More account informationTopped-up balance¥1,234.56<$0.01Granted balanceNo bonus availableMoreView usageTop up +Harness Mock (TEST ONLY)138****0000More account informationTopped-up balance¥1,234.56<$0.01MoreView usageTop up diff --git a/packages/client/ui-settings-account/tests/expected/details-zh.txt b/packages/client/ui-settings-account/tests/expected/details-zh.txt index 75bc72e8f8..725935948e 100644 --- a/packages/client/ui-settings-account/tests/expected/details-zh.txt +++ b/packages/client/ui-settings-account/tests/expected/details-zh.txt @@ -1 +1 @@ -Harness Mock (TEST ONLY)138****0000更多账号信息充值余额¥1,234.56<$0.01赠金余额暂无可用赠金更多查询用量充值 +Harness Mock (TEST ONLY)138****0000更多账号信息充值余额¥1,234.56<$0.01更多查询用量充值 From 30a39133978da2f218d7b306bdde2585b7ae079a Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:12:35 +0800 Subject: [PATCH 28/42] =?UTF-8?q?fix(web):=20=E7=BB=9F=E4=B8=80=E5=BC=95?= =?UTF-8?q?=E5=AF=BC=E9=A1=B5=E8=AE=BE=E7=BD=AE=E5=85=A5=E5=8F=A3=E6=96=87?= =?UTF-8?q?=E6=A1=88=E5=B9=B6=E8=A6=86=E7=9B=96=E8=B5=A0=E9=87=91=E8=A1=8C?= =?UTF-8?q?=E5=88=86=E9=9A=94=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 引导页两条确认提示改为指向实际入口:Settings → Account / Settings → General, 中文同步为 设置 → 账号与余额 / 设置 → 通用设置。赠金行可见性提为 showBonusRow, 并断言隐藏赠金行时其分隔线一并移除。 --- .../ui-settings-account/src/client/AccountSection.tsx | 3 ++- .../ui-settings-account/src/client/locales/onboarding.ts | 8 ++++---- .../ui-settings-account/tests/account.client.spec.tsx | 4 ++++ 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/client/ui-settings-account/src/client/AccountSection.tsx b/packages/client/ui-settings-account/src/client/AccountSection.tsx index 953b48cc53..6d020ad29a 100644 --- a/packages/client/ui-settings-account/src/client/AccountSection.tsx +++ b/packages/client/ui-settings-account/src/client/AccountSection.tsx @@ -93,6 +93,7 @@ export function AccountSection({ t, useAccount, useTheme, start, cancel, openPla const wallets = details?.balance?.status === 'ready' ? details.balance.value : undefined const bonusWallets = details?.balance?.status === 'ready' ? details.balance.bonusWallets.filter(wallet => new Big(wallet.balance).gt(0)) : [] + const showBonusRow = details?.balance?.status !== 'ready' || bonusWallets.length > 0 const attempt = state?.attempt const active = attempt !== null && attempt !== undefined && ['initializing', 'waiting-browser', 'exchanging', 'committing'].includes(attempt.phase) @@ -183,7 +184,7 @@ export function AccountSection({ t, useAccount, useTheme, start, cancel, openPla ? {t(!signedIn ? 'balanceSignedOut' : 'loading')} : platformLink(t('balanceUnavailable'), css.unavailableLink)}
- {signedIn && (details?.balance?.status !== 'ready' || bonusWallets.length > 0) && <> + {signedIn && showBonusRow && <>
{t('bonusBalance')} diff --git a/packages/client/ui-settings-account/src/client/locales/onboarding.ts b/packages/client/ui-settings-account/src/client/locales/onboarding.ts index d3b1e22be8..509986446e 100644 --- a/packages/client/ui-settings-account/src/client/locales/onboarding.ts +++ b/packages/client/ui-settings-account/src/client/locales/onboarding.ts @@ -30,10 +30,10 @@ export const onboardingCopy = { onboardingBack: '上一步', onboardingSkip: '跳过', onboardingSkipTitle: '跳过设置?', - onboardingSkipDescription: '你可以随时前往 个人中心 → 通用设置 中调整工作过程、性能与用量展示方式或开启代码工作工具。', + onboardingSkipDescription: '你可以随时前往 设置 → 通用设置 中调整工作过程、性能与用量展示方式或开启代码工作工具。', onboardingKeepSetting: '继续设置', onboardingNoCreditTitle: '暂时跳过充值?', - onboardingNoCreditDescription: '没有可用额度时,DeepSeek Harness 将无法开始新的任务。你可以稍后前往 个人中心 → 账号与余额 进行充值。', + onboardingNoCreditDescription: '没有可用额度时,DeepSeek Harness 将无法开始新的任务。你可以稍后前往 设置 → 账号与余额 进行充值。', onboardingUnderstood: '我知道了', onboardingGoTopUp: '前往充值', onboardingSaveFailed: '未能保存设置,请重试。', @@ -73,10 +73,10 @@ export const onboardingEnglishCopy: Record onboardingBack: 'Back', onboardingSkip: 'Skip', onboardingSkipTitle: 'Skip setup?', - onboardingSkipDescription: 'You can change how progress, performance, and usage are displayed, or enable Coding Tools, anytime in Profile → General Settings.', + onboardingSkipDescription: 'You can change how progress, performance, and usage are displayed, or enable Coding Tools, anytime in Settings → General.', onboardingKeepSetting: 'Continue setup', onboardingNoCreditTitle: 'Skip adding credits?', - onboardingNoCreditDescription: 'DeepSeek Harness can’t start new tasks without credits. You can add them later in Settings → Accounts.', + onboardingNoCreditDescription: 'DeepSeek Harness can’t start new tasks without credits. You can add them later in Settings → Account.', onboardingUnderstood: 'Got it', onboardingGoTopUp: 'Add credits', onboardingSaveFailed: 'Could not save settings. Try again.', diff --git a/packages/client/ui-settings-account/tests/account.client.spec.tsx b/packages/client/ui-settings-account/tests/account.client.spec.tsx index 577e213b5a..cae2d7fac0 100644 --- a/packages/client/ui-settings-account/tests/account.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/account.client.spec.tsx @@ -13,6 +13,7 @@ import type { BonusNotice } from '../src/client/bonus-notices.ts' import type { AccountMenuProps } from '../src/client/AccountMenu.tsx' import type {} from '../src/client/index.ts' import { en, zh, type AccountKey } from '../src/client/locales.ts' +import css from '../src/client/AccountSection.module.css' afterEach(() => { cleanup(); vi.unstubAllGlobals() }) @@ -441,6 +442,7 @@ it.each([en, zh])('renders positive bonus wallets separately from recharge balan }) expect(screen.getByText(copy.balance).parentElement!.textContent).toBe(`${copy.balance}¥209.00$20.07`) expect(screen.getByText(copy.bonusBalance).parentElement!.textContent).toBe(`${copy.bonusBalance}¥5.00<$0.01`) + expect(screen.getByRole('region').querySelectorAll(`.${css.divider}`)).toHaveLength(2) await expect(`${screen.getByRole('region').textContent}\n`) .toMatchFileSnapshot(`./expected/bonus-${copy === en ? 'en' : 'zh'}.txt`) }) @@ -451,6 +453,8 @@ it.each([[], [{ currency: 'CNY' as const, balance: '0.00' }, { currency: 'USD' a balance: { status: 'ready', value: [{ currency: 'CNY', balance: '0' }], bonusWallets }, }) expect(screen.queryByText(en.bonusBalance)).toBeNull() + // The divider above the bonus row goes with it. + expect(screen.getByRole('region').querySelectorAll(`.${css.divider}`)).toHaveLength(1) expect(screen.getByText('¥0.00')).toBeTruthy() expect(screen.queryByText('¥-1.00')).toBeNull() }, From 34b424952c725513e45ccda740daa641c6768cae Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 29 Sep 2026 11:39:41 +0800 Subject: [PATCH 29/42] Address Intel Node signing review feedback --- .../feature/2026-09-14-desktop-primary-runtime.i18n.yaml | 4 ++-- .../feature/2026-09-14-desktop-primary-runtime.md | 2 +- .../feature/2026-09-14-desktop-primary-runtime.zh.md | 2 +- apps/desktop/scripts/macos-runtime.ts | 9 +++++---- apps/desktop/scripts/prepare-dsh.ts | 4 ++-- apps/desktop/tests/macos-cache-policy.spec.ts | 2 +- apps/desktop/tests/macos-runtime.spec.ts | 8 ++++---- 7 files changed, 16 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml index 56a6a1ef1e..683d995312 100644 --- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml @@ -9,8 +9,8 @@ en: 36bf235f0821ef6a zh: a86bf07af03d54a3 /agent-note-desktop-primary-runtime/decision: - en: b3b104e314762322 - zh: 77478c007893201f + en: 3d2241842f5a2a83 + zh: 8617717f0337d76c /agent-note-desktop-primary-runtime/alternatives-considered: en: 566fd91c30cb0a0a zh: 70758ae2617f6ebb diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md index a1491ca0c4..9c961517b4 100644 --- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md +++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md @@ -16,7 +16,7 @@ The application version, top-level interpreter/package-manager versions, Python The shared Node build entry downloads and hash-verifies the complete locked wheel set and unpacks library files into site-packages. This avoids build-host Python and pip version selection without implementing dependency resolution or general wheel installation. Auxiliary scripts, including XlsxWriter's VBA extraction script, remain under the wheel's `.data/scripts` directory; command-line entry-point wrappers are outside this library payload. Other `.data` installation schemes are rejected. Native smoke executes the final payload after temporary files are removed, checking the exact locked distribution set plus bundled pip, Python and pinned wheel versions, dependency completeness and editable Office document round trips, so interpreter links must survive relocation. Smoke checks disable bytecode writes to keep validation artifacts out of the shipped payload. -macOS grants `com.apple.security.cs.allow-jit` only to the standalone Node executable. Hardened-runtime signing without that entitlement prevents V8 from allocating its code region. Interpreter and library smoke checks run after signing as well as after staging cleanup; a valid signature alone does not establish executable behavior. +macOS grants `com.apple.security.cs.allow-jit` to the standalone Node executable and LibreOffice helpers. The x64 Node executable additionally requires `com.apple.security.cs.allow-unsigned-executable-memory`: with JIT-only signing, the bundled x64 Node fails during startup under Rosetta in V8 `OS::SetPermissions`. ARM64 Node and LibreOffice helpers retain JIT-only permissions; the additional permission is confined to the interpreter that requires it. Interpreter and library smoke checks run after signing as well as after staging cleanup; a valid signature alone does not establish executable behavior. Windows signed packaging separates materialization from execution with supervised signing stages for the primary runtime and application production dependencies. PE inspection identifies content independently of extensions, excludes non-PE foreign-platform Node addons and refuses directory links. Public-key verification uses batches of 32 files with at most four processes and drains each batch before returning failure; hardware operations remain serial. Valid vendor signatures remain intact; only unsigned files receive the configured EV signature. Invalid existing signatures fail before hardware access, and each new signature is checked for validity, timestamp and certificate identity before the next file. Electron-builder's copy-time signing hook preserves runtime executables only after exact-byte and signature verification; the same serial queue rejects later tasks if preservation fails. The existing per-user interlock, serialized signer and redacted journal own hardware calls; hardware failures prohibit retries and later stages. Public timestamp attempts follow the [signature completion policy](../process/2026-09-17-windows-signature-completion.md). Runtime execution receives no signing credentials and follows complete verification. Development and unsigned preparation retain native smoke without automatic hardware access. Runtime inventories record signed bytes. Electron-builder unpacks detected dependency PE files and preserves signed copies. Final signature verification and ASAR payload and Host smoke use a private native cache before release completion. diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md index 6447086a69..c2f1a4f105 100644 --- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md +++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md @@ -16,7 +16,7 @@ Desktop 将 Python、Node.js、pnpm、数据处理库和 Office 创作库作为 共享 Node 构建入口下载并校验完整锁定 wheel 集的哈希,将库文件解压到 site-packages。这避免选择构建主机的 Python 和 pip 版本,也无需实现依赖解析或通用 wheel 安装。XlsxWriter 的 VBA 提取脚本等辅助脚本保留在 wheel 的 `.data/scripts` 目录中;命令行入口包装器不属于该库产物。其他 `.data` 安装方案会被拒绝。本机 smoke 在临时文件删除后执行最终产物,检查精确锁定的分发包集合与内置 pip、Python 与固定 wheel 版本、依赖完整性及可编辑 Office 文档的写入和读取,因此解释器链接必须在迁移后仍有效。Smoke 检查禁用字节码写入,避免把验证产物纳入分发内容。 -macOS 仅向独立 Node 可执行文件授予 `com.apple.security.cs.allow-jit`。缺少此权限的强化运行时签名会阻止 V8 分配代码区域。解释器和库的 smoke 检查在签名后以及暂存清理后执行;签名有效本身不能证明程序可运行。 +macOS 向独立 Node 可执行文件和 LibreOffice 辅助程序授予 `com.apple.security.cs.allow-jit`。x64 Node 可执行文件还需要 `com.apple.security.cs.allow-unsigned-executable-memory`:仅授予 JIT 权限时,内置 x64 Node 在 Rosetta 下启动会在 V8 `OS::SetPermissions` 中失败。ARM64 Node 和 LibreOffice 辅助程序保持仅有 JIT 权限;额外权限仅授予需要它的解释器。解释器和库的 smoke 检查在签名后以及暂存清理后执行;签名有效本身不能证明程序可运行。 Windows 签名打包通过受监督的签名阶段覆盖第一方运行时和应用生产依赖,将文件准备与执行分开。PE 检查独立于扩展名识别文件内容,排除其他平台的非 PE Node 原生插件,并拒绝目录链接。公钥验签每个进程处理最多 32 个文件,同时最多运行四个进程,返回失败前等待当前批次全部结束;硬件操作仍串行执行。有效的上游签名保持不变;仅未签名文件使用配置的 EV 证书签名。已有签名无效时,在访问硬件前失败;每个新签名通过有效性、时间戳和证书身份检查后,才处理下一个文件。electron-builder 复制阶段的签名钩子仅在逐字节比对和验签通过后保留运行时可执行文件;保留验证失败时,同一串行队列拒绝后续任务。硬件调用复用现有的用户级互锁、串行签名器和脱敏日志;硬件失败不允许重试或继续后续阶段。公开时间戳尝试遵循[签名完成规则](../process/2026-09-17-windows-signature-completion.zh.md)。运行时执行不接收签名凭据,并在完整验签后进行。开发和未签名准备保留本机 smoke,不自动访问硬件。运行时清单记录签名后的字节。electron-builder 将检测到的依赖 PE 文件放在归档外,并保留已签名的副本。发布完成前,最终签名检查以及 ASAR 载荷和 Host 冒烟检查使用私有原生缓存。 diff --git a/apps/desktop/scripts/macos-runtime.ts b/apps/desktop/scripts/macos-runtime.ts index bcfcfd9932..3d5fabdbed 100644 --- a/apps/desktop/scripts/macos-runtime.ts +++ b/apps/desktop/scripts/macos-runtime.ts @@ -25,7 +25,7 @@ function magic(path: string): string { * @param appId - Release application identifier. * @param expected - Required signing identity. * @param arch - Target runtime architecture, independent of the signing host. - * @param cacheDirectory Optional content-addressed cache; requires the keychain-owned signing probe. + * @param cacheDirectory - Optional content-addressed cache; requires the keychain-owned signing probe. * @returns Number of signed native files. */ export async function signMacOSRuntime( @@ -41,11 +41,12 @@ export async function signMacOSRuntime( const path = files[next++] if (path === undefined) return const identifier = `${appId}.runtime.${createHash('sha256').update(path).digest('hex')}` - const needsJit = path === 'dependencies/node/bin/node' + const isNode = path === 'dependencies/node/bin/node' + const needsJit = isNode || /^node_modules\/@deepseek-ai\/libreoffice-kit-darwin-(?:arm64|x64)\/bin\/libreoffice-kit$/u.test(path) - const jitPlist = path === 'dependencies/node/bin/node' && arch === 'x64' + const entitlementsFile = isNode && arch === 'x64' ? 'node-x64-entitlements.plist' : 'jit-entitlements.plist' - const entitlements = needsJit ? join(import.meta.dirname, jitPlist) : undefined + const entitlements = needsJit ? join(import.meta.dirname, entitlementsFile) : undefined const file = join(root, path) const thin = ['cefaedfe', 'cffaedfe', 'feedface', 'feedfacf'].includes(magic(file)) if (cacheDirectory !== undefined && policy !== undefined && thin) { diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts index a70c03509e..442e01757e 100644 --- a/apps/desktop/scripts/prepare-dsh.ts +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -29,7 +29,7 @@ import { import { signMacOSRuntime, } from './macos-runtime.ts' -import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { desktopTargetPlatform, resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' import { desktopRuntimeFileExclusion } from './runtime-file-policy.ts' import { selectOfficeEngine } from '../../../scripts/libreoffice-packages.mjs' @@ -130,7 +130,7 @@ async function main(): Promise { await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:install', () => runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile'])) const packageSet = readDesktopCorePackageSet(BUILD_ROOT, release.version) const targetName = resolveDesktopBuildTarget() - const target = { platform: process.platform, arch: targetName.endsWith('arm64') ? 'arm64' as const : 'x64' as const } + const target = { platform: process.platform, arch: desktopTargetPlatform(targetName).arch } const modules = join(BUILD_ROOT, 'node_modules') const officeManifest = JSON.parse(readFileSync(join(modules, '@deepseek-ai/libreoffice-kit/package.json'), 'utf8')) const officeEngine = selectOfficeEngine(officeManifest, target) diff --git a/apps/desktop/tests/macos-cache-policy.spec.ts b/apps/desktop/tests/macos-cache-policy.spec.ts index 2c56986c8f..5344ab200f 100644 --- a/apps/desktop/tests/macos-cache-policy.spec.ts +++ b/apps/desktop/tests/macos-cache-policy.spec.ts @@ -51,8 +51,8 @@ it('includes certificate, identifier and entitlement contents in the cache polic const create = macOSCachePolicy('probe') const original = create('com.example.file', identity).policy expect(create('com.other.file', identity).policy).not.toBe(original) - expect(create('com.example.file', identity, join(import.meta.dirname, '../scripts/jit-entitlements.plist')).policy).not.toBe(original) const jit = create('com.example.file', identity, join(import.meta.dirname, '../scripts/jit-entitlements.plist')).policy + expect(jit).not.toBe(original) expect(create('com.example.file', identity, join(import.meta.dirname, '../scripts/node-x64-entitlements.plist')).policy).not.toBe(jit) certificate = 'renewed-certificate' expect(macOSCachePolicy('probe')('com.example.file', identity).policy).not.toBe(original) diff --git a/apps/desktop/tests/macos-runtime.spec.ts b/apps/desktop/tests/macos-runtime.spec.ts index ab248522ae..fdcb61600a 100644 --- a/apps/desktop/tests/macos-runtime.spec.ts +++ b/apps/desktop/tests/macos-runtime.spec.ts @@ -47,20 +47,20 @@ it('awaits other signers before rejecting and permitting output cleanup', async expect(verifyMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'b.node'), identity) }) -it.each(['arm64', 'x64'] as const)('limits %s executable-memory permissions to the standalone Node interpreter', async (arch) => { +it.each(['arm64', 'x64'] as const)('selects %s Node entitlements and keeps helpers JIT-only', async (arch) => { const path = root() mkdirSync(join(path, 'dependencies/node/bin'), { recursive: true }) const node = join(path, 'dependencies/node/bin/node') const addon = join(path, 'addon.node') - const helpers = ['arm64', 'x64'].map(arch => join(path, 'node_modules/@deepseek-ai', `libreoffice-kit-darwin-${arch}`, 'bin/libreoffice-kit')) + const helpers = ['arm64', 'x64'].map(helperArch => join(path, 'node_modules/@deepseek-ai', `libreoffice-kit-darwin-${helperArch}`, 'bin/libreoffice-kit')) for (const helper of helpers) mkdirSync(join(helper, '..'), { recursive: true }) for (const file of [node, addon, ...helpers]) writeFileSync(file, Buffer.from('cffaedfe00000000', 'hex')) await signMacOSRuntime(path, 'com.example.app', identity, arch) const nodePlist = join(import.meta.dirname, '../scripts', arch === 'x64' ? 'node-x64-entitlements.plist' : 'jit-entitlements.plist') expect(signMacOSRuntimeCode).toHaveBeenCalledWith(node, expect.any(String), identity, nodePlist) const xml = readFileSync(nodePlist, 'utf8') - expect(xml).toContain('com.apple.security.cs.allow-jit') - expect(xml.includes('com.apple.security.cs.allow-unsigned-executable-memory')).toBe(arch === 'x64') + expect(xml).toMatch(/com\.apple\.security\.cs\.allow-jit<\/key>\s*/u) + expect(/com\.apple\.security\.cs\.allow-unsigned-executable-memory<\/key>\s*/u.test(xml)).toBe(arch === 'x64') for (const file of helpers) { expect(signMacOSRuntimeCode).toHaveBeenCalledWith(file, expect.any(String), identity, join(import.meta.dirname, '../scripts/jit-entitlements.plist')) From 7a0c6ebd9e002e02181bfc8809e0e085922e8e69 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Tue, 29 Sep 2026 11:46:03 +0800 Subject: [PATCH 30/42] refine(web): show only the package-name example in the install guide The install guide listed the package name, a GitHub address, and a local directory as numbered steps; only the package name needs an example. Keep that entry and drop the index badge, give the item's hint and example the title's text color, and balance the container's vertical padding. The trust box above Install also states that installed plugins do not update automatically, that upgrading means uninstalling and installing the new version, and that later releases will keep improving that experience. --- .../cancelled.expected.md | 2 +- .../another-way.expected.md | 12 ++------ .../plugin-install-github/mirror.expected.md | 2 +- apps/web/tests/plugin-manager.e2e.ts | 11 +++++-- .../client/ui-plugin-manager/README.i18n.yaml | 4 +-- packages/client/ui-plugin-manager/README.md | 2 +- .../client/ui-plugin-manager/README.zh.md | 2 +- .../src/client/PluginManagerPage.module.css | 30 +++++++------------ .../src/client/PluginManagerPage.tsx | 12 ++++---- .../ui-plugin-manager/src/client/locales.ts | 18 +++-------- .../tests/components.client.spec.tsx | 15 +++++----- 11 files changed, 45 insertions(+), 65 deletions(-) diff --git a/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md b/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md index 80e2756b5a..9999ca0af5 100644 --- a/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md +++ b/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md @@ -7,5 +7,5 @@ - text: slow-package - button "插件安装引导和示例" - button "安装源 默认安装源" - - note: 请确认插件来源可信。插件在本机以你的权限运行,来源不明的插件可能损坏 DeepSeek Harness,或读取和泄露你的数据。 + - note: 请确认插件来源可信。插件在本机以你的权限运行,来源不明的插件可能损坏 DeepSeek Harness,或读取和泄露你的数据。 插件安装后,暂不支持自动更新。若需升级,请先卸载再安装新版,后续版本会持续改善升级体验。 - button "安装" diff --git a/apps/web/tests/expected/plugin-install-github/another-way.expected.md b/apps/web/tests/expected/plugin-install-github/another-way.expected.md index 0932737ec2..66a9943dd0 100644 --- a/apps/web/tests/expected/plugin-install-github/another-way.expected.md +++ b/apps/web/tests/expected/plugin-install-github/another-way.expected.md @@ -7,16 +7,8 @@ - button "安装源 中国大陆镜像源" - list: - listitem: - - text: 包名 插件包名即 npm 包名(如 dsh-xxx 或 @作者/插件名),社区插件的 README 安装命令中 dsh plugin add 或 pnpm add 之后的部分。 示例: + - text: 填入插件 npm 包名 插件包名即 npm 包名(如 dsh-xxx 或 @作者/插件名),社区插件的 README 安装命令中 dsh plugin add 或 pnpm add 之后的部分。 示例: - code: dsh-plugin-whale-pet - button "填入示例 dsh-plugin-whale-pet": 填入示例 - - listitem: - - text: GitHub 仓库地址 插件在 GitHub 上的开源仓库地址,也支持其他 Git 仓库。 示例: - - code: https://github.com/author/dsh-plugin - - button "填入示例 https://github.com/author/dsh-plugin": 填入示例 - - listitem: - - text: 本地插件目录 本机上插件目录的绝对路径,适用于自行开发或已下载的插件。 示例: - - code: /Users/name/my-plugin - - button "填入示例 /Users/name/my-plugin": 填入示例 - - note: 请确认插件来源可信。插件在本机以你的权限运行,来源不明的插件可能损坏 DeepSeek Harness,或读取和泄露你的数据。 + - note: 请确认插件来源可信。插件在本机以你的权限运行,来源不明的插件可能损坏 DeepSeek Harness,或读取和泄露你的数据。 插件安装后,暂不支持自动更新。若需升级,请先卸载再安装新版,后续版本会持续改善升级体验。 - button "安装" [disabled] diff --git a/apps/web/tests/expected/plugin-install-github/mirror.expected.md b/apps/web/tests/expected/plugin-install-github/mirror.expected.md index e414fac6ce..4f672b66d5 100644 --- a/apps/web/tests/expected/plugin-install-github/mirror.expected.md +++ b/apps/web/tests/expected/plugin-install-github/mirror.expected.md @@ -5,5 +5,5 @@ - /placeholder: 例如 dsh-plugin-whale-pet - button "插件安装引导和示例" - button "安装源 中国大陆镜像源" - - note: 请确认插件来源可信。插件在本机以你的权限运行,来源不明的插件可能损坏 DeepSeek Harness,或读取和泄露你的数据。 + - note: 请确认插件来源可信。插件在本机以你的权限运行,来源不明的插件可能损坏 DeepSeek Harness,或读取和泄露你的数据。 插件安装后,暂不支持自动更新。若需升级,请先卸载再安装新版,后续版本会持续改善升级体验。 - button "安装" [disabled] diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index b89279d173..03c1f43128 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -835,13 +835,18 @@ describe('web e2e: plugin manager', () => { const install = dialog.getByRole('button', { name: '安装', exact: true }) expect(await install.isDisabled()).toBe(true) expect(await dialog.getByRole('note').textContent()).toContain('请确认插件来源可信') + expect(await dialog.innerText()).toContain('暂不支持自动更新') await dialog.getByRole('button', { name: '插件安装引导和示例' }).click() + // The guide carries the package-name example only; Git and path specs stay valid without guide copy. + await expect.poll(() => dialog.getByRole('listitem').count()).toBe(1) + await dialog.getByRole('button', { name: '填入示例 dsh-plugin-whale-pet' }).click() + expect(await field.inputValue()).toBe('dsh-plugin-whale-pet') + expect(await dialog.getByRole('status').count()).toBe(0) for (const [example, hint] of [ ['https://github.com/author/dsh-plugin', '请替换为实际的 Git 仓库地址'], ['/Users/name/my-plugin', '请替换为本机插件目录的实际路径'], - ]) { - await dialog.getByRole('button', { name: `填入示例 ${example}` }).click() - expect(await field.inputValue()).toBe(example) + ] as const) { + await field.fill(example) expect(await dialog.getByRole('status').textContent()).toBe(hint) } await field.fill('/actual/plugin-directory') diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 29acda2d0f..02c89f4673 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -18,8 +18,8 @@ en: b7d2396da72ecac4 zh: 4ec8336478dd39c7 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle: - en: 20e7310ab6867389 - zh: eed2957381abc0bd + en: f7c3980e7bf9aa63 + zh: 9a5b9633151690d7 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-a-bundle: en: abc90531efa889d5 zh: 0562570f85e69c35 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index c075e53cf0..79e0ba8275 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -41,7 +41,7 @@ On first use, when pnpm uses the unconfigured official npm registry and npmmirro The Host sends GET requests to `https://registry.npmjs.org/-/ping` and `https://registry.npmmirror.com/-/ping` through its normal fetch proxy. A 2xx response wins; redirects and failures do not. The remaining request is cancelled, both response bodies are discarded, and cleanup completes before the result returns. The default deadline is 1500 ms and results, including unavailable results, are cached for five minutes. Configure `registryProbeTimeoutMs` and `registryProbeCacheTtlMs` on `ui-plugin-manager`; `registryProbeEnabled: false` disables probing. If both requests fail or time out, the existing default remains selected. No IP-location service or Session content is involved. -**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. Enter in the package or custom-registry field submits the install request, but Enter used to confirm an input-method candidate does not. **Install guide and examples** under the field opens a guide that shows the three common forms with an example each; **Use example** drops one into the field. Git and local-path examples require the actual address or path; a hint appears while either example remains in the field. A trust warning stays above **Install**, including when the guide is collapsed. **Registry** beside it names the registry the install asks first and unfolds the choice: pnpm's own registry, named by the registry it actually names — the official npm registry, the mainland China mirror, or its host — which keeps the neutral default name until the Host reads it, is listed once, and carries its host unless that host is already its name; each mirror the Host configured (`pluginManager.registries`), npmmirror as the mainland China mirror; and a typed http(s) address. The options use an opaque selector panel, fill rows only on hover, and show hosts in tertiary text without parentheses. The custom-address area highlights on hover; clicking anywhere in it selects the custom registry and focuses its input, whose focus style matches the package field. Keyboard focus alone preserves the selected registry; activating the custom radio or editing the address selects the custom registry. Tab moves from the selected radio to the address, and Shift+Tab moves back; leaving either end closes the selector and returns focus to **Registry**. Switching to an offered registry retains the custom URL draft for switching back. Submitting an invalid custom address blocks installation, opens the registry selector, and focuses the address. Invalid package and custom-address fields keep a red error border and add the shared blue inset focus ring on focus. The options float over the dialog from the control, so unfolding them never lengthens the card, and the card's content scrolls when the guide makes it taller than the viewport. The initial choice follows the response comparison above; the choice is then remembered in this browser (`localStorage`) and starts the next dialog, and a remembered address the Host no longer offers stays as a typed one and remains the requested address when installation starts before the registry list returns. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`) at the chosen registry: a name the list already shows, a name no registry has, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing; when no registry could be reached, the sentence names every one asked. The install then starts at the registry that answered. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. While the Host moves on to another registry, the installing screen says which registry could not serve the package and which is asked now, and each pnpm run behind the details carries a badge naming its registry. A failed install says what went wrong in one line, as the Host attributed it — no registry could be reached, naming every one asked; the host a GitHub address or a tarball link is fetched from could not be reached, which no registry helps; the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand, and **Change registry** beside it when the Host laid the failure at the registry it asked, which returns to the spec with the registry options open; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate. +**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. Enter in the package or custom-registry field submits the install request, but Enter used to confirm an input-method candidate does not. **Install guide and examples** under the field opens a guide that shows the package-name example; **Use example** drops it into the field. A field still holding the Git or local-path example keeps a hint asking for the actual value. A trust box above **Install** stays visible including when the guide is collapsed, and adds that installed plugins do not update automatically: upgrading means uninstalling and installing the new version, which later releases will keep improving. **Registry** beside it names the registry the install asks first and unfolds the choice: pnpm's own registry, named by the registry it actually names — the official npm registry, the mainland China mirror, or its host — which keeps the neutral default name until the Host reads it, is listed once, and carries its host unless that host is already its name; each mirror the Host configured (`pluginManager.registries`), npmmirror as the mainland China mirror; and a typed http(s) address. The options use an opaque selector panel, fill rows only on hover, and show hosts in tertiary text without parentheses. The custom-address area highlights on hover; clicking anywhere in it selects the custom registry and focuses its input, whose focus style matches the package field. Keyboard focus alone preserves the selected registry; activating the custom radio or editing the address selects the custom registry. Tab moves from the selected radio to the address, and Shift+Tab moves back; leaving either end closes the selector and returns focus to **Registry**. Switching to an offered registry retains the custom URL draft for switching back. Submitting an invalid custom address blocks installation, opens the registry selector, and focuses the address. Invalid package and custom-address fields keep a red error border and add the shared blue inset focus ring on focus. The options float over the dialog from the control, so unfolding them never lengthens the card, and the card's content scrolls when the guide makes it taller than the viewport. The initial choice follows the response comparison above; the choice is then remembered in this browser (`localStorage`) and starts the next dialog, and a remembered address the Host no longer offers stays as a typed one and remains the requested address when installation starts before the registry list returns. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`) at the chosen registry: a name the list already shows, a name no registry has, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing; when no registry could be reached, the sentence names every one asked. The install then starts at the registry that answered. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. While the Host moves on to another registry, the installing screen says which registry could not serve the package and which is asked now, and each pnpm run behind the details carries a badge naming its registry. A failed install says what went wrong in one line, as the Host attributed it — no registry could be reached, naming every one asked; the host a GitHub address or a tarball link is fetched from could not be reached, which no registry helps; the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand, and **Change registry** beside it when the Host laid the failure at the registry it asked, which returns to the spec with the registry options open; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate. During preparation and download, **Cancel install** asks the Host to stop the run and waits for confirmation. Loading the bundle cannot be cancelled. Closing with ×, Escape, or the backdrop hides the dialog immediately and requests cancellation when possible. **View installation** reopens the same task with its output; another installation cannot start while its outcome is pending or unconfirmed. Confirmed cancellation returns to the spec and shows a toast; the manifest and lockfile are restored, while downloaded files can remain. A lost installation response triggers a request to recover the result; **Check installation status** and reconnect retry that request. If the Host has no active request, **Installation result unavailable** allows returning to editing after checking the plugin list. A cancellation sent before acceptance waits and retries automatically; a failed cancellation can be retried manually. Hidden tasks report their outcome through toasts without reopening the dialog. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 0d8e0f5afd..db44a60359 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -41,7 +41,7 @@ kind: "package-reference" Host 通过普通 fetch 代理向 `https://registry.npmjs.org/-/ping` 和 `https://registry.npmmirror.com/-/ping` 发送 GET 请求,以最先返回 2xx 的源为结果;重定向和失败不参与选择。随后取消另一条请求、丢弃两个响应体,并在清理完成后返回。默认时限为 1500 毫秒,包括不可用结果在内的探测结果均缓存五分钟。在 `ui-plugin-manager` 上配置 `registryProbeTimeoutMs` 和 `registryProbeCacheTtlMs`;`registryProbeEnabled: false` 关闭探测。两条请求都失败或超时时保留现有默认源。不使用 IP 地区服务,也不发送 Session 内容。 -**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。在包名或自定义安装源输入框中按 Enter 会提交安装请求,但用于确认输入法候选词的 Enter 不会触发安装。输入框下方的**插件安装引导和示例**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。Git 地址和本地路径示例需要替换为实际值;输入框中保留示例时会显示提醒。可信来源提示始终显示在**安装**按钮上方,引导收起后也可见。旁边的**安装源**写着安装首先询问的注册表,展开后可选:pnpm 自身的注册表,按它实际指向的源命名——npm 官方源、中国大陆镜像源,其余以主机名显示——Host 读不到时保留中性的默认名称,只列一次,选项标题带上主机名(主机名即名称时不重复显示);Host 配置的每个镜像(`pluginManager.registries`),npmmirror 显示为中国大陆镜像源;以及手动输入的 http(s) 地址。选项使用不透明选择面板,仅悬停行使用底色,主机名不加括号并使用三级文字色。自定义地址区域在悬停时高亮;点击其中任意位置会选中自定义源并聚焦输入框,焦点样式与包名输入框一致。仅通过键盘聚焦不会改变已选安装源;激活自定义单选项或编辑地址才会选中自定义源。Tab 从已选单选项移到地址框,Shift+Tab 反向返回;从任一端移出时关闭选择器,并将焦点交还**安装源**按钮。切到预设源时保留自定义地址草稿,切回后继续使用。提交无效的自定义地址时会阻止安装,展开安装源选择器并聚焦地址框。包名和自定义地址输入框无效时保留红色错误边框,聚焦时加上共享的蓝色内嵌焦点环。选项从控件上浮在对话框之上,展开不会拉长卡片;引导使卡片高过视口时,卡片内容可以滚动。初始选择遵循上文的响应比较规则;之后选择记在本浏览器(`localStorage`)里,下次打开对话框从它开始,Host 不再提供的已记住地址保留为手动输入的地址;在安装源列表返回前开始安装,也仍请求该地址。**安装**先让 Host 在所选注册表读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、所有注册表都没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑;所有注册表都连不上时,这句话列出问过的每一个。随后安装从作答的那个注册表开始。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。Host 改问另一个注册表时,安装中界面会说明哪个源没能提供这个包、现在改问哪个,详情里每次 pnpm 运行都带一个写明所用源的标记。安装失败时按 Host 的归因用一行话说明原因——所有安装源都连不上,并列出问过的每一个;GitHub 地址或压缩包链接自身的主机连不上,换源无济于事;包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边,Host 把失败归于所问注册表时还在旁边提供**更换安装源**,回到 spec 输入界面并展开安装源选项;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。 +**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。在包名或自定义安装源输入框中按 Enter 会提交安装请求,但用于确认输入法候选词的 Enter 不会触发安装。输入框下方的**插件安装引导和示例**展开一段引导,给出包名示例;**填入示例**把它填进输入框。输入框中仍是 Git 地址或本地路径示例时会显示提醒,要求替换为实际值。**安装**按钮上方的提示框在引导收起后也可见,并说明插件安装后暂不支持自动更新:升级需先卸载再安装新版,后续版本会持续改善升级体验。旁边的**安装源**写着安装首先询问的注册表,展开后可选:pnpm 自身的注册表,按它实际指向的源命名——npm 官方源、中国大陆镜像源,其余以主机名显示——Host 读不到时保留中性的默认名称,只列一次,选项标题带上主机名(主机名即名称时不重复显示);Host 配置的每个镜像(`pluginManager.registries`),npmmirror 显示为中国大陆镜像源;以及手动输入的 http(s) 地址。选项使用不透明选择面板,仅悬停行使用底色,主机名不加括号并使用三级文字色。自定义地址区域在悬停时高亮;点击其中任意位置会选中自定义源并聚焦输入框,焦点样式与包名输入框一致。仅通过键盘聚焦不会改变已选安装源;激活自定义单选项或编辑地址才会选中自定义源。Tab 从已选单选项移到地址框,Shift+Tab 反向返回;从任一端移出时关闭选择器,并将焦点交还**安装源**按钮。切到预设源时保留自定义地址草稿,切回后继续使用。提交无效的自定义地址时会阻止安装,展开安装源选择器并聚焦地址框。包名和自定义地址输入框无效时保留红色错误边框,聚焦时加上共享的蓝色内嵌焦点环。选项从控件上浮在对话框之上,展开不会拉长卡片;引导使卡片高过视口时,卡片内容可以滚动。初始选择遵循上文的响应比较规则;之后选择记在本浏览器(`localStorage`)里,下次打开对话框从它开始,Host 不再提供的已记住地址保留为手动输入的地址;在安装源列表返回前开始安装,也仍请求该地址。**安装**先让 Host 在所选注册表读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、所有注册表都没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑;所有注册表都连不上时,这句话列出问过的每一个。随后安装从作答的那个注册表开始。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。Host 改问另一个注册表时,安装中界面会说明哪个源没能提供这个包、现在改问哪个,详情里每次 pnpm 运行都带一个写明所用源的标记。安装失败时按 Host 的归因用一行话说明原因——所有安装源都连不上,并列出问过的每一个;GitHub 地址或压缩包链接自身的主机连不上,换源无济于事;包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边,Host 把失败归于所问注册表时还在旁边提供**更换安装源**,回到 spec 输入界面并展开安装源选项;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。 准备和下载期间,**取消安装**会请求 Host 停止运行并等待确认。加载组合包的阶段不可取消。点击 ×、按 Escape 或点击遮罩会立即隐藏对话框,并在可以取消时请求取消。**查看安装任务**会重新打开同一任务并保留输出;结果待定或尚未确认时不能发起另一项安装。确认取消后回到 spec 输入界面并显示 toast;manifest 与 lockfile 已恢复,已下载文件可能保留。安装响应丢失后会请求恢复结果;**核对安装状态**和重连会重试该请求。Host 已无活动请求时,**未能获取安装结果**允许检查插件列表后返回编辑。早于接收确认的取消请求会等待并自动重试;取消失败可手动重试。隐藏的任务通过 toast 通知结果,不会重新弹出对话框。 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index f2818ba19a..5c1240711b 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -567,20 +567,27 @@ display: flex; flex-direction: column; gap: 10px; - padding: 8px 14px 14px; + padding: 8px 14px; border: 0.5px solid var(--dsw-alias-border-l4); border-radius: var(--dsw-radius-lg); background: var(--dsw-alias-bg-layer-1); } .guideHint { - margin: 0; + margin: 6px 0 0; font-size: 12px; line-height: 18px; - color: var(--dsw-alias-label-secondary); + color: var(--dsw-alias-label-tertiary); } /* Trust guidance stays beside the install action, including while examples are collapsed. */ +.installSafetyText { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + .installSafety { display: flex; align-items: flex-start; @@ -620,21 +627,6 @@ align-self: center; } -.guideIndex { - display: inline-flex; - flex: none; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - border-radius: 999px; - corner-shape: round; - background: var(--dsw-alias-bg-module-platform); - font-size: 11px; - font-weight: 500; - color: var(--dsw-alias-label-secondary); -} - .guideMain { display: flex; flex: 1; @@ -653,7 +645,7 @@ .guideExample { font-size: 12px; line-height: 20px; - color: var(--dsw-alias-label-secondary); + color: var(--dsw-alias-label-tertiary); overflow-wrap: anywhere; } diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 92d06906f0..f2e10bed0f 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -668,11 +668,9 @@ interface GuideExample { readonly hintKey: PluginManagerLocaleKey } -/** The spec forms the install guide shows, each with an example the person can drop into the field. */ +/** The spec form the install guide shows, with an example the person can drop into the field. */ const GUIDE_EXAMPLES = [ { key: 'id', titleKey: 'installGuideIdTitle', exampleKey: 'installGuideIdExample', hintKey: 'installGuideIdHint' }, - { key: 'git', titleKey: 'installGuideGitTitle', exampleKey: 'installGuideGitExample', hintKey: 'installGuideGitHint' }, - { key: 'path', titleKey: 'installGuidePathTitle', exampleKey: 'installGuidePathExample', hintKey: 'installGuidePathHint' }, ] as const satisfies readonly GuideExample[] /** The one-line reading of a classified pnpm failure. */ @@ -892,7 +890,10 @@ function InstallDialog({