details} content={thumbnail} />)
- const anchor = screen.getByRole('button', { name: 'details' })
- const matches = vi.spyOn(anchor, 'matches').mockReturnValue(true)
- fireEvent.focus(anchor)
- expect(screen.getByText('thumbnail')).toBeTruthy()
- const activeElement = vi.spyOn(document, 'activeElement', 'get').mockReturnValue(anchor)
- fireEvent.pointerLeave(anchor.parentElement as HTMLElement)
- act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
- expect(screen.getByText('thumbnail')).toBeTruthy()
- activeElement.mockRestore()
- fireEvent.blur(anchor, { relatedTarget: document.body })
- expect(screen.queryByText('thumbnail')).toBeNull()
- matches.mockRestore()
-})
-
it('dismisses an inline hover without anchor focus and lets the next Escape reach the owner', () => {
const ownerKey = vi.fn()
render(
@@ -895,46 +706,3 @@ it('flips inline media above a bottom link and constrains it without covering th
expect(card.style.top).toBe('42px')
expect(Number.parseFloat(card.style.maxHeight)).toBe(window.innerHeight - 50)
})
-
-it('closes a compact copyable card after its mouse-focused card loses the pointer', () => {
- const { wrapper } = mount({ copyText: 'value' })
- fireEvent.pointerEnter(wrapper)
- act(() => { vi.advanceTimersByTime(500) })
- const card = screen.getByRole('button', { name: 'Copy: value' })
- act(() => { card.focus() })
- expect(document.activeElement).toBe(card)
- fireEvent.pointerLeave(card)
- act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
- expect(screen.queryByText('card body')).toBeNull()
-})
-
-it('closes a callout hover after click toggling leaves mouse focus on the anchor', () => {
- render(details} content="body" />)
- const anchor = screen.getByRole('button', { name: 'details' })
- act(() => { anchor.focus() })
- fireEvent.click(anchor)
- fireEvent.click(anchor)
- fireEvent.pointerEnter(anchor.parentElement as HTMLElement)
- act(() => { vi.advanceTimersByTime(500) })
- expect(screen.getByText('body')).toBeTruthy()
- fireEvent.pointerLeave(anchor.parentElement as HTMLElement)
- act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
- expect(screen.queryByText('body')).toBeNull()
-})
-
-it('scrolls an open callout with the focused anchor keyboard controls', () => {
- render(details} content="body" />)
- const anchor = screen.getByRole('button', { name: 'details' })
- fireEvent.click(anchor)
- const scroll = document.querySelector('[data-hover-card-scroll]') as HTMLElement
- Object.defineProperty(scroll, 'clientHeight', { configurable: true, value: 160 })
- Object.defineProperty(scroll, 'scrollHeight', { configurable: true, value: 640 })
- expect(fireEvent.keyDown(scroll, { key: 'PageDown' })).toBe(true)
- expect(scroll.scrollTop).toBe(0)
- for (const [key, top] of [['ArrowDown', 20], ['ArrowUp', 0], ['PageDown', 160], ['PageUp', 0], ['End', 640], ['Home', 0]] as const) {
- expect(fireEvent.keyDown(anchor, { key })).toBe(false)
- expect(scroll.scrollTop).toBe(top)
- }
- fireEvent.keyDown(anchor, { key: 'Tab' })
- expect(screen.queryByText('body')).toBeNull()
-})
From 012bfe03e2b34fee592785ffae2c78ae14f1f71a Mon Sep 17 00:00:00 2001
From: yudshj
Date: Wed, 21 Oct 2026 10:47:02 +0800
Subject: [PATCH 23/43] style(ui-user-questions): expand long copy with chevron
icon buttons
The expand and collapse controls reuse the composer's 24px icon button
with the existing downward and upward chevron icons instead of text. Each
carries a localized accessible name, which a portaled Tooltip shows on
hover. The fade at the end of clipped copy narrows to the 24px button.
---
apps/web/tests/question-composer.e2e.ts | 39 ++++++++-------
.../client/ui-user-questions/README.i18n.yaml | 4 +-
packages/client/ui-user-questions/README.md | 2 +-
.../client/ui-user-questions/README.zh.md | 2 +-
.../src/client/QuestionComposer.module.css | 41 +++++-----------
.../src/client/QuestionComposer.tsx | 49 +++++++++++--------
.../ui-user-questions/src/client/locales.ts | 12 +++--
.../user-questions-composer.client.spec.tsx | 46 +++++++++--------
8 files changed, 99 insertions(+), 96 deletions(-)
diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts
index c96d102bdd..f6a0acfa63 100644
--- a/apps/web/tests/question-composer.e2e.ts
+++ b/apps/web/tests/question-composer.e2e.ts
@@ -70,12 +70,12 @@ async function capMetrics(field: Locator): Promise<{ textLines: number; scrolls:
/**
* Read how the composer draws one long-copy element: the lines it shows,
- * whether CSS clipped it, whether its text overflows the line budget, and the
- * label of its toggle while that toggle is visible.
+ * whether CSS clipped it, whether its text overflows the line budget, and what
+ * its toggle does while that toggle is visible.
* @param element - a question heading or an option description.
* @returns the drawn state of the copy.
*/
-function readCopy(element: HTMLElement): { lines: number; clipped: boolean; overflows: boolean; toggle: string | undefined } {
+function readCopy(element: HTMLElement): { lines: number; clipped: boolean; overflows: boolean; toggle: 'expand' | 'collapse' | undefined } {
const style = getComputedStyle(element)
const toggle = element.tagName === 'H2'
? element.nextElementSibling
@@ -86,7 +86,7 @@ function readCopy(element: HTMLElement): { lines: number; clipped: boolean; over
clipped: style.maskImage !== 'none',
overflows: element.scrollHeight > element.clientHeight + 0.5,
toggle: toggle instanceof HTMLElement && getComputedStyle(toggle).visibility === 'visible'
- ? toggle.textContent ?? undefined
+ ? toggle.getAttribute('aria-expanded') === 'true' ? 'collapse' : 'expand'
: undefined,
}
}
@@ -414,7 +414,7 @@ describe('web e2e: resident question composer round trip', () => {
const copy = await description.evaluate(readCopy)
expect(copy.lines).toBeLessThanOrEqual(2)
expect(copy.clipped).toBe(copy.overflows)
- expect(copy.toggle).toBe(copy.clipped ? 'More' : undefined)
+ expect(copy.toggle).toBe(copy.clipped ? 'expand' : undefined)
}
scrollStates.push(squeeze.scrolls)
expect(squeeze.titleInScrollport).toBe(true)
@@ -432,14 +432,14 @@ describe('web e2e: resident question composer round trip', () => {
const blue = composer.getByRole('checkbox', { name: 'Blue' })
const blueText = composer.locator('[data-option-description]').first()
const blueToggle = composer.locator(`button[aria-controls=${JSON.stringify(await blueText.getAttribute('id'))}]`)
- await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'More' })
+ await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
await blueToggle.click()
expect(await blue.getAttribute('aria-checked')).toBe('false')
expect(await blueToggle.getAttribute('aria-expanded')).toBe('true')
- expect(await blueText.evaluate(readCopy)).toMatchObject({ clipped: false, overflows: false, toggle: 'Show less' })
+ expect(await blueText.evaluate(readCopy)).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' })
await blueToggle.click()
// A collapsed copy's scroll timeline reports the overflow from the next frame.
- await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'More' })
+ await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
await page.setViewportSize(originalViewport)
// Multi-line custom answer: the field is a textarea whose hidden mirror
@@ -622,7 +622,7 @@ describe('web e2e: resident question composer round trip', () => {
await composer.getByRole('heading', { name: '选择 HF provider 的思考等级?' }).waitFor()
expect(await composer.getByRole('heading').evaluate(readCopy))
.toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined })
- expect(await composer.getByRole('button', { name: 'More' }).count()).toBe(0)
+ expect(await composer.getByRole('button', { name: 'Show the full question' }).count()).toBe(0)
const rows = await composer.locator('[data-option-row]').evaluateAll(options => options.map((row) => {
const label = row.querySelector('[data-option-line] > span:first-child')
const description = row.querySelector('[data-option-description]')
@@ -726,15 +726,15 @@ describe('web e2e: resident question composer round trip', () => {
// Past its three-line budget the question keeps two lines; past its
// two-line budget a description keeps one. Copy within budget shows in full.
await expect.poll(() => heading.evaluate(readCopy))
- .toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'More' })
+ .toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' })
expect(await descriptionOf('Explicit install').evaluate(readCopy))
- .toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'More' })
+ .toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
expect(await descriptionOf('Keep the default edge').evaluate(readCopy))
.toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined })
for (const copy of await composer.locator('[data-option-description]').all()) {
const drawn = await copy.evaluate(readCopy)
expect(drawn.clipped).toBe(drawn.overflows)
- expect(drawn.toggle).toBe(drawn.clipped ? 'More' : undefined)
+ expect(drawn.toggle).toBe(drawn.clipped ? 'expand' : undefined)
expect(drawn.lines).toBeLessThanOrEqual(drawn.clipped ? 1 : 2)
}
if (viewport.width === 512) {
@@ -758,15 +758,17 @@ describe('web e2e: resident question composer round trip', () => {
// scrollport while navigation and submission stay reachable.
const questionToggle = await toggleFor(heading)
expect(await questionToggle.getAttribute('aria-expanded')).toBe('false')
+ expect(await questionToggle.getAttribute('aria-label')).toBe('Show the full question')
await questionToggle.click()
expect(await questionToggle.getAttribute('aria-expanded')).toBe('true')
+ expect(await questionToggle.getAttribute('aria-label')).toBe('Show less of the question')
const expandedQuestion = await heading.evaluate(readCopy)
- expect(expandedQuestion).toMatchObject({ clipped: false, overflows: false, toggle: 'Show less' })
+ expect(expandedQuestion).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' })
expect(expandedQuestion.lines).toBeGreaterThan(3)
expect(await questionToggle.evaluate(belowText, await heading.elementHandle())).toBe(true)
expect(await readMetrics()).toEqual({ footerFits: true, scrolls: true, titleInScrollport: true })
await questionToggle.click()
- await expect.poll(() => heading.evaluate(readCopy)).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'More' })
+ await expect.poll(() => heading.evaluate(readCopy)).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' })
const detailText = composer.getByText(detail, { exact: true })
expect(await detailText.evaluate(node => node.closest('[data-question-scroll]') !== null)).toBe(true)
@@ -782,10 +784,11 @@ describe('web e2e: resident question composer round trip', () => {
const descriptionId = await explicitText.getAttribute('id')
expect(await explicit.getAttribute('aria-describedby')).toBe(descriptionId)
expect(await descriptionToggle.getAttribute('aria-describedby')).toBe(descriptionId)
+ expect(await descriptionToggle.getAttribute('aria-label')).toBe('Show the full description of “Explicit install”')
await descriptionToggle.click()
expect(await explicit.getAttribute('aria-checked')).toBe('false')
const expandedDescription = await explicitText.evaluate(readCopy)
- expect(expandedDescription).toMatchObject({ clipped: false, overflows: false, toggle: 'Show less' })
+ expect(expandedDescription).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' })
expect(expandedDescription.lines).toBeGreaterThan(2)
expect(await descriptionToggle.evaluate(belowText, await explicitText.elementHandle())).toBe(true)
expect(await explicitText.evaluate((text) => {
@@ -794,7 +797,7 @@ describe('web e2e: resident question composer round trip', () => {
child.getBoundingClientRect().bottom <= row.getBoundingClientRect().bottom + 0.5)
})).toBe(true)
- // The toggle's ink stays readable on the card in both themes.
+ // The toggle's icon keeps non-text contrast on the card in both themes.
const palette: string[] = []
const originalDarkTheme = await page.locator('body').getAttribute('data-ds-dark-theme')
try {
@@ -814,7 +817,7 @@ describe('web e2e: resident question composer round trip', () => {
const foreground = luminance(colors.text)
const background = luminance(colors.surface)
expect((Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05))
- .toBeGreaterThanOrEqual(4.5)
+ .toBeGreaterThanOrEqual(3)
palette.push(colors.surface)
}
expect(palette[0]).not.toBe(palette[1])
@@ -826,7 +829,7 @@ describe('web e2e: resident question composer round trip', () => {
}
await descriptionToggle.click()
await expect.poll(() => explicitText.evaluate(readCopy))
- .toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'More' })
+ .toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
await composer.getByRole('checkbox', { name: 'Explicit install' }).click()
const answer = composer.getByRole('textbox')
diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml
index 68f0c3442a..974ab9c64e 100644
--- a/packages/client/ui-user-questions/README.i18n.yaml
+++ b/packages/client/ui-user-questions/README.i18n.yaml
@@ -18,8 +18,8 @@
en: 8d3815c19858ac85
zh: 94fc905fa202d334
/deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering:
- en: fda2aac4a93d6550
- zh: aa5ba8322b566f37
+ en: 2173107a23e9f92a
+ zh: d3a6d17e50dd4b51
/deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back:
en: 10975f68ff9c6fcc
zh: 92246aadc363f0eb
diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md
index f31e20910f..9f2fe46110 100644
--- a/packages/client/ui-user-questions/README.md
+++ b/packages/client/ui-user-questions/README.md
@@ -29,7 +29,7 @@ When the agent asks a question, the attached card temporarily occupies the compo
### Answering
-A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A question appears in full up to three rendered lines; a longer one shows two, fades at the end of the second, and offers **More** there, which expands it in place. Option descriptions use 13px text with a 20px line height and sit beside their label when they fit, otherwise below it; a description appears in full up to two lines, and a longer one shows one line with the same fade and **More**. Expanded copy keeps **Show less** on a line below it. CSS scroll timelines decide which copy overflows; a browser without them shows all copy in full. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`.
+A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A question appears in full up to three rendered lines; a longer one shows two, fades at the end of the second, and places a downward-chevron expand button there that expands it in place. Option descriptions use 13px text with a 20px line height and sit beside their label when they fit, otherwise below it; a description appears in full up to two lines, and a longer one shows one line with the same fade and expand button. Expanded copy keeps an upward-chevron collapse button on a line below it. Both buttons carry a localized accessible name and a hover tooltip. CSS scroll timelines decide which copy overflows; a browser without them shows all copy in full. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`.
### Reading a settled question back
diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md
index c487cdc47b..2ac78bf4c1 100644
--- a/packages/client/ui-user-questions/README.zh.md
+++ b/packages/client/ui-user-questions/README.zh.md
@@ -29,7 +29,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户
### 作答
-用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。问题在实际排版不超过三行时完整显示;更长的问题只显示两行,第二行末尾渐隐并提供「全部」,点击后原地展开。选项说明使用 13px 字号和 20px 行高,放得下时排在标签旁,否则排在标签下方;说明不超过两行时完整显示,更长的说明只显示一行,同样渐隐并提供「全部」。展开后的文字下方单独一行显示「收起」。由 CSS 滚动时间线判断文字是否溢出;不支持该特性的浏览器完整显示全部文字。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。
+用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。问题在实际排版不超过三行时完整显示;更长的问题只显示两行,第二行末尾渐隐并放置向下箭头的展开按钮,点击后原地展开。选项说明使用 13px 字号和 20px 行高,放得下时排在标签旁,否则排在标签下方;说明不超过两行时完整显示,更长的说明只显示一行,同样渐隐并放置展开按钮。展开后的文字下方单独一行显示向上箭头的收起按钮。两种按钮都有本地化的无障碍名称和悬停提示。由 CSS 滚动时间线判断文字是否溢出;不支持该特性的浏览器完整显示全部文字。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。
### 回看已结束的问题
diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css
index a2de7c3ff6..fa75de76e4 100644
--- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css
+++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css
@@ -109,7 +109,7 @@
/* Long copy keeps one line fewer than its budget once it overflows: a
question shows in full up to three lines, an option description up to two;
longer copy drops its last line, fades at the end of the line before, and
- shows its toggle there. Each box keeps its full budget, plus a 2px
+ shows its expand button there. Each box keeps its full budget, plus a 2px
allowance for glyphs taller than the line, so the scroll timeline that
reports the overflow never depends on the collapsed layout. A negative
bottom margin, not a smaller box, gives the dropped line back to the row. */
@@ -121,7 +121,7 @@
}
.questionTitle[data-expanded] {
- padding-bottom: 22px;
+ padding-bottom: 24px;
}
.titlePreview {
@@ -142,7 +142,7 @@
@keyframes question-clipped {
from, to {
margin-bottom: calc(-1lh - 2px);
- mask-image: linear-gradient(#000, #000), linear-gradient(to right, #000 calc(100% - 72px), transparent calc(100% - 48px));
+ mask-image: linear-gradient(#000, #000), linear-gradient(to right, #000 calc(100% - 56px), transparent calc(100% - 28px));
mask-size: 100% 1lh, 100% 1lh;
mask-position: 0 0, 0 1lh;
mask-repeat: no-repeat;
@@ -151,8 +151,8 @@
.questionToggle {
position: absolute;
- right: -6px;
- bottom: 0;
+ right: -4px;
+ bottom: -1px;
animation-name: copy-toggle-shown;
animation-fill-mode: both;
animation-timeline: --question-copy;
@@ -400,7 +400,7 @@
@keyframes description-clipped {
from, to {
margin-bottom: calc(-1lh - 2px);
- mask-image: linear-gradient(to right, #000 calc(100% - 72px), transparent calc(100% - 48px));
+ mask-image: linear-gradient(to right, #000 calc(100% - 56px), transparent calc(100% - 28px));
mask-size: 100% 1lh;
mask-repeat: no-repeat;
}
@@ -408,16 +408,16 @@
.descriptionToggle {
position: absolute;
- right: 6px;
- bottom: 7px;
+ right: 8px;
+ bottom: 6px;
animation-name: copy-toggle-shown;
animation-fill-mode: both;
animation-timeline: --description-copy;
}
-/* Expanded copy keeps its toggle on a line of its own below the text. */
+/* Expanded copy keeps its collapse button on a line of its own below the text. */
.optionRow[data-expanded] .option {
- padding-bottom: 30px;
+ padding-bottom: 32px;
}
.questionTitle[data-expanded] .titlePreview,
@@ -426,18 +426,8 @@
animation: none;
}
+/* Shares .iconButton's 24px round control; hidden until its copy overflows or expands. */
.copyToggle {
- padding: 1px 6px;
- border: 0;
- border-radius: var(--dsw-radius-sm);
- background: transparent;
- color: var(--dsw-alias-label-secondary);
- font: inherit;
- font-size: 13px;
- line-height: 20px;
- font-weight: 400;
- white-space: nowrap;
- cursor: pointer;
visibility: hidden;
}
@@ -453,13 +443,6 @@
animation: none;
}
-@media (hover: hover) {
- .copyToggle:hover {
- background: var(--dsw-alias-interactive-bg-hover);
- color: var(--dsw-alias-label-primary);
- }
-}
-
/* Without scroll timelines clipped copy cannot be told apart, so it shows in full. */
@supports not (animation-timeline: scroll()) {
.titlePreview,
@@ -640,7 +623,7 @@
}
.descriptionToggle {
- right: 0;
+ right: 2px;
}
.footer {
diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx
index cfd8eeed0c..b8cdeae85b 100644
--- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx
+++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx
@@ -6,7 +6,7 @@ import clsx from 'clsx'
import {
Button, IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronLeftOutlineRegular,
IconChevronRightOutlineRegular, IconChevronUpOutlineRegular, IconCloseOutlineRegular,
- IconEditOutlineRegular, MarkdownText,
+ IconEditOutlineRegular, MarkdownText, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { planReviewOf, type QuestionAnswer, type QuestionCardSnapshot, type QuestionComposerProps } from './contract/slots.ts'
import type { PendingQuestion } from './contract/slots.ts'
@@ -250,6 +250,8 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
// CSS decides which copy is clipped; only the user's expansions are state.
const [expanded, setExpanded] = useState(NOTHING_EXPANDED)
+ const questionToggleLabel = t(expanded.has('question') ? 'question.collapse' : 'question.expand')
+
const toggleCopy = (copy: 'question' | number): void => {
setExpanded((current) => {
const next = new Set(current)
@@ -524,15 +526,18 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
>
{question.question}
-
+
+
+
{question.detail !== undefined && (
@@ -543,6 +548,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
const display = parseRecommendedLabel(option.label)
const hasDescription = option.description !== undefined && option.description.trim() !== ''
const descriptionExpanded = expanded.has(optionIndex)
+ const descriptionToggleLabel = t(descriptionExpanded ? 'option.collapse' : 'option.expand', { option: display.label })
const descriptionId = `${descriptionIdPrefix}-question-${String(index)}-option-${String(optionIndex)}-description`
return (
{hasDescription && (
-
+
+
+
)}
)
diff --git a/packages/client/ui-user-questions/src/client/locales.ts b/packages/client/ui-user-questions/src/client/locales.ts
index 4995c365e4..0e8d3e6bdf 100644
--- a/packages/client/ui-user-questions/src/client/locales.ts
+++ b/packages/client/ui-user-questions/src/client/locales.ts
@@ -25,8 +25,10 @@ export const zh = {
'nav.maximize': '展开问题卡片',
'nav.cancel': '放弃整组问题',
'nav.close': '收起问题面板,可从工具调用重新打开',
- 'text.expand': '全部',
- 'text.collapse': '收起',
+ 'question.expand': '展开完整问题',
+ 'question.collapse': '收起问题',
+ 'option.expand': '展开“{option}”的完整说明',
+ 'option.collapse': '收起“{option}”的说明',
'option.recommended': '推荐',
'custom.placeholder': '输入你的答案',
'action.skip': '跳过',
@@ -65,8 +67,10 @@ export const en = {
'nav.maximize': 'Expand the question card',
'nav.cancel': 'Dismiss all questions',
'nav.close': 'Close the panel — reopen it from the tool call',
- 'text.expand': 'More',
- 'text.collapse': 'Show less',
+ 'question.expand': 'Show the full question',
+ 'question.collapse': 'Show less of the question',
+ 'option.expand': 'Show the full description of “{option}”',
+ 'option.collapse': 'Show less of “{option}”',
'option.recommended': 'Recommended',
'custom.placeholder': 'Type your answer',
'action.skip': 'Skip',
diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx
index c1ab2c6f7f..7562833067 100644
--- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx
+++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx
@@ -315,32 +315,34 @@ describe('QuestionComposer', () => {
// CSS shows each toggle only while its copy overflows; every candidate is rendered.
const heading = screen.getByRole('heading', { name: 'How should the release graph change?' })
- const [questionToggle, descriptionToggle, ...rest] = screen.getAllByRole('button', { name: '全部' })
- expect(rest).toHaveLength(0)
- expect(questionToggle?.getAttribute('aria-controls')).toBe(heading.id)
+ const questionToggle = screen.getByRole('button', { name: '展开完整问题' })
+ const descriptionToggle = screen.getByRole('button', { name: '展开“Explicit install”的完整说明' })
+ expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })).toEqual([questionToggle, descriptionToggle])
+ expect(questionToggle.getAttribute('aria-controls')).toBe(heading.id)
const option = screen.getByRole('radio', { name: 'Explicit install' })
const text = screen.getByText(description)
expect(option.getAttribute('aria-describedby')).toBe(text.id)
- expect(descriptionToggle?.getAttribute('aria-controls')).toBe(text.id)
- expect(descriptionToggle?.getAttribute('aria-describedby')).toBe(text.id)
- expect(descriptionToggle?.getAttribute('aria-expanded')).toBe('false')
+ expect(descriptionToggle.getAttribute('aria-controls')).toBe(text.id)
+ expect(descriptionToggle.getAttribute('aria-describedby')).toBe(text.id)
+ expect(descriptionToggle.getAttribute('aria-expanded')).toBe('false')
- fireEvent.click(descriptionToggle as HTMLElement)
- expect(descriptionToggle?.getAttribute('aria-expanded')).toBe('true')
- expect(descriptionToggle?.textContent).toBe('收起')
+ fireEvent.click(descriptionToggle)
+ expect(descriptionToggle.getAttribute('aria-expanded')).toBe('true')
+ expect(descriptionToggle.getAttribute('aria-label')).toBe('收起“Explicit install”的说明')
expect(text.closest('[data-option-row]')?.hasAttribute('data-expanded')).toBe(true)
expect(screen.getByText(description)).toBe(text)
expect(option.getAttribute('aria-checked')).toBe('false')
- expect(questionToggle?.getAttribute('aria-expanded')).toBe('false')
+ expect(questionToggle.getAttribute('aria-expanded')).toBe('false')
- fireEvent.click(questionToggle as HTMLElement)
- expect(questionToggle?.getAttribute('aria-expanded')).toBe('true')
+ fireEvent.click(questionToggle)
+ expect(questionToggle.getAttribute('aria-expanded')).toBe('true')
+ expect(questionToggle.getAttribute('aria-label')).toBe('收起问题')
expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(true)
- fireEvent.click(questionToggle as HTMLElement)
- fireEvent.click(descriptionToggle as HTMLElement)
+ fireEvent.click(questionToggle)
+ fireEvent.click(descriptionToggle)
expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(false)
expect(text.closest('[data-option-row]')?.hasAttribute('data-expanded')).toBe(false)
- expect(screen.getAllByRole('button', { name: '全部' })).toEqual([questionToggle, descriptionToggle])
+ expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })).toEqual([questionToggle, descriptionToggle])
})
it('resets scrolling and expanded copy when the question changes', () => {
@@ -361,8 +363,9 @@ describe('QuestionComposer', () => {
expect(screen.getByRole('region', { name: 'First question?' })).toBeTruthy()
const firstScroll = screen.getByText('First question?').closest('[data-question-scroll]') as HTMLElement
firstScroll.scrollTop = 80
- for (const toggle of screen.getAllByRole('button', { name: '全部' })) fireEvent.click(toggle)
- expect(screen.getAllByRole('button', { name: '收起' })).toHaveLength(2)
+ for (const toggle of screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })) fireEvent.click(toggle)
+ expect(screen.getByRole('button', { name: '收起问题' }).getAttribute('aria-expanded')).toBe('true')
+ expect(screen.getByRole('button', { name: '收起“Continue”的说明' }).getAttribute('aria-expanded')).toBe('true')
fireEvent.click(screen.getByRole('radio', { name: 'Continue' }))
const secondScroll = screen.getByText('Second question?').closest('[data-question-scroll]') as HTMLElement
@@ -370,11 +373,12 @@ describe('QuestionComposer', () => {
expect(screen.getByRole('region', { name: 'Second question?' })).toBeTruthy()
expect(secondScroll).toBe(firstScroll)
expect(secondScroll.scrollTop).toBe(0)
- expect(screen.queryByRole('button', { name: '收起' })).toBeNull()
+ expect(screen.queryByRole('button', { name: '收起问题' })).toBeNull()
+ expect(screen.queryByRole('button', { name: '收起“Continue”的说明' })).toBeNull()
expect(secondScroll.querySelector('[data-expanded]')).toBeNull()
expect(screen.queryByText(firstDescription)).toBeNull()
- const descriptionToggle = screen.getAllByRole('button', { name: '全部' })[1]
- expect(document.getElementById(descriptionToggle?.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription)
+ const descriptionToggle = screen.getByRole('button', { name: '展开“Continue”的完整说明' })
+ expect(document.getElementById(descriptionToggle.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription)
})
it('renders plan detail through the shared assistant Markdown primitive', () => {
@@ -948,7 +952,7 @@ describe('review card', () => {
expect(chosen.getAttribute('aria-checked')).toBe('true')
expect(chosen.disabled).toBe(true)
// Long copy stays readable on a frozen card.
- expect(screen.getAllByRole('button', { name: '全部' }).map(toggle => toggle.disabled)).toEqual([false, false, false])
+ expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ }).map(toggle => toggle.disabled)).toEqual([false, false, false])
// Nothing is left to send, and the unused free-text field would read as
// somewhere to type.
expect(screen.queryByRole('button', { name: '跳过' })).toBeNull()
From 5d5bd8eb412c1a520fa8ce95a5139afdb643fde3 Mon Sep 17 00:00:00 2001
From: yudshj
Date: Wed, 21 Oct 2026 11:03:13 +0800
Subject: [PATCH 24/43] test(web): re-check long question copy across chat and
window widths
A long question with long, width-sensitive, and short option descriptions
is driven through the transcript width handle at its narrowest and widest
positions, an opened and collapsed right sidebar, and a smaller and
restored window. At each settled width every collapsed copy follows its
own overflow, the expanded description stays expanded, and the actions
stay reachable; collapsing the sidebar or restoring the window returns
the wide state exactly.
---
apps/web/tests/question-composer.e2e.ts | 153 ++++++++++++++++++++++++
1 file changed, 153 insertions(+)
diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts
index f6a0acfa63..a120479c59 100644
--- a/apps/web/tests/question-composer.e2e.ts
+++ b/apps/web/tests/question-composer.e2e.ts
@@ -861,6 +861,159 @@ describe('web e2e: resident question composer round trip', () => {
}
}, 60_000)
+ it.skipIf(MODE === 'record')('re-evaluates long copy when the chat column or the window changes width', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-question-chat-width'))
+ const sessionId = answeredSession
+ expect(sessionId).toBeDefined()
+ const agent = scaffold.ctx.agents.get(sessionId as SessionId)
+ expect(agent).toBeDefined()
+ // Sized for the chat-width drag range: the question takes three lines or
+ // fewer in the widest chat column and more in the narrowest, and the staged
+ // description takes two lines or fewer and more.
+ const question = `${Array.from({ length: 2 }, () => 'The rollout plan must name the release owner, the verification lanes that gate it, and the rollback path before the candidate is cut.').join(' ')} Which plan should we adopt?`
+ const staged = 'Stage the rollout behind the release channel first, keep the experimental client out of every shipped template, ask plugin authors to declare the dependency explicitly, and publish the upgrade note together with the cross-platform validation results.'
+ const explicit = Array.from({ length: 24 }, (_, index) =>
+ `Step ${String(index + 1)} removes the experimental client from shipped templates and records the validation before rollout.`).join(' ')
+ const controller = new AbortController()
+ const asked = scaffold.ctx.userQuestions.ask({
+ agent: agent as NonNullable,
+ signal: controller.signal,
+ questions: [{
+ id: 'rollout',
+ header: 'Rollout decision',
+ question,
+ options: [
+ { label: 'Explicit install', description: explicit },
+ { label: 'Stage the rollout', description: staged },
+ { label: 'Keep the default edge', description: 'Keep the current setup.' },
+ ],
+ }],
+ })
+ const outcome = asked.then(value => ({ value }), (error: unknown) => ({ error }))
+
+ const composer = page.locator('[data-question-key]')
+ const frame = page.locator('[style*="grid-template-columns"]').first()
+ const original = page.viewportSize() ?? { width: 1680, height: 1000 }
+ let bodyCompleted = false
+ try {
+ await page.setViewportSize({ width: 1680, height: 1000 })
+ await composer.waitFor({ timeout: 30_000 })
+ const heading = composer.getByRole('heading', { name: question })
+ const descriptionOf = (label: string) => composer.locator('[data-option-row]', {
+ has: page.getByRole('radio', { name: label, exact: true }),
+ }).locator('[data-option-description]')
+ const toggleFor = async (copy: Locator) =>
+ composer.locator(`button[aria-controls=${JSON.stringify(await copy.getAttribute('id'))}]`)
+ // Settled layout: no AppFrame column transition in flight, then every
+ // collapsed copy drawn as its own overflow decides, the expanded copy
+ // still expanded, and the actions reachable.
+ const settle = async () => {
+ await expect.poll(() => frame.evaluate(node => node.hasAttribute('data-animating'))).toBe(false)
+ const read = async () => {
+ const card = await composer.locator('section').evaluate((section) => {
+ const scroll = section.querySelector('[data-question-scroll]')
+ const submit = [...section.querySelectorAll('button')]
+ .find(button => button.textContent === 'Submit')
+ const submitBox = submit?.getBoundingClientRect()
+ return {
+ width: Math.round(section.getBoundingClientRect().width),
+ actionsReachable: submitBox !== undefined && submitBox.top >= -0.5 && submitBox.bottom <= window.innerHeight + 0.5,
+ titleInScrollport: scroll?.contains(section.querySelector('h2')) === true,
+ }
+ })
+ return {
+ card,
+ question: await heading.evaluate(readCopy),
+ explicit: await descriptionOf('Explicit install').evaluate(readCopy),
+ staged: await descriptionOf('Stage the rollout').evaluate(readCopy),
+ short: await descriptionOf('Keep the default edge').evaluate(readCopy),
+ }
+ }
+ await expect.poll(async () => {
+ const state = await read()
+ const decided = (copy: Awaited>['question'], budget: number) =>
+ copy.clipped === copy.overflows && copy.toggle === (copy.clipped ? 'expand' : undefined)
+ && copy.lines <= (copy.clipped ? budget - 1 : budget)
+ return state.card.actionsReachable && state.card.titleInScrollport
+ && decided(state.question, 3) && decided(state.staged, 2) && decided(state.short, 2)
+ && !state.explicit.clipped && !state.explicit.overflows && state.explicit.toggle === 'collapse'
+ }).toBe(true)
+ return await read()
+ }
+ // The right transcript handle widens the content column by twice its
+ // outward travel. The composer seat covers the handle's lower part, so
+ // the gesture grabs it beside the transcript.
+ const dragChatWidth = async (travel: number) => {
+ const box = await page.locator('[data-width-handle="right"]').boundingBox()
+ if (box === null) throw new Error('The chat width handle is not rendered')
+ const x = box.x + box.width / 2
+ const y = box.y + 120
+ await page.mouse.move(x, y)
+ await page.mouse.down()
+ try {
+ await page.mouse.move(x + travel, y, { steps: 8 })
+ } finally {
+ await page.mouse.up()
+ }
+ }
+
+ // Expanded copy is the user's choice and survives every width change below.
+ const explicitToggle = await toggleFor(descriptionOf('Explicit install'))
+ await explicitToggle.click()
+ expect(await explicitToggle.getAttribute('aria-expanded')).toBe('true')
+ expect(await composer.getByRole('radio', { name: 'Explicit install', exact: true }).getAttribute('aria-checked')).toBe('false')
+
+ await dragChatWidth(-400)
+ const narrow = await settle()
+ expect(narrow.question).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' })
+ expect(narrow.staged).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
+
+ await dragChatWidth(600)
+ const wide = await settle()
+ expect(wide.card.width).toBeGreaterThan(narrow.card.width)
+ expect(wide.question).toMatchObject({ clipped: false, overflows: false, toggle: undefined })
+ expect(wide.staged).toEqual({ lines: 2, clipped: false, overflows: false, toggle: undefined })
+ expect(wide.short).toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined })
+
+ // The right sidebar narrows the conversation column, and collapsing it restores the wide state.
+ await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click()
+ const withSidebar = await settle()
+ expect(withSidebar.card.width).toBeLessThan(wide.card.width)
+ await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click()
+ expect(await settle()).toEqual(wide)
+
+ // A smaller window clamps the dragged width; restoring the window restores it.
+ await page.setViewportSize({ width: 900, height: 700 })
+ const smallWindow = await settle()
+ expect(smallWindow.card.width).toBeLessThan(wide.card.width)
+ expect(smallWindow.question).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' })
+ expect(smallWindow.staged).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
+ await page.setViewportSize({ width: 1680, height: 1000 })
+ expect(await settle()).toEqual(wide)
+
+ await explicitToggle.click()
+ await expect.poll(() => descriptionOf('Explicit install').evaluate(readCopy))
+ .toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
+ await composer.getByRole('radio', { name: 'Stage the rollout', exact: true }).click()
+ await composer.getByRole('button', { name: 'Submit', exact: true }).click()
+ expect(await outcome).toEqual({ value: { answers: [{ id: 'rollout', selected: ['Stage the rollout'] }] } })
+ await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0)
+ bodyCompleted = true
+ } finally {
+ controller.abort()
+ // The dragged width is a persisted preference; dropping it and resizing republishes the default.
+ const cleanup = await Promise.allSettled([
+ outcome,
+ page.evaluate(() => { localStorage.removeItem('dsh.conversation.contentWidth') })
+ .then(() => page.setViewportSize(original)),
+ ])
+ if (bodyCompleted) {
+ const failure = cleanup.find((result): result is PromiseRejectedResult => result.status === 'rejected')
+ if (failure !== undefined) throw failure.reason
+ }
+ }
+ }, 90_000)
+
})
describe.skipIf(MODE === 'record')('web e2e: cancelled question transcript', () => {
From f8a094e4656594d9ec99feb688e5559268e23df2 Mon Sep 17 00:00:00 2001
From: yudshj
Date: Wed, 21 Oct 2026 11:48:30 +0800
Subject: [PATCH 25/43] fix(ui-user-questions): label every copy toggle Expand
or Collapse
Option names made the toggle tooltips as long and unpredictable as the
option labels themselves. Every question and description toggle now uses
the same Expand and Collapse copy, and the question toggle, like the
description toggles, is described by the text it controls.
---
apps/web/tests/question-composer.e2e.ts | 9 ++++---
.../client/ui-user-questions/README.i18n.yaml | 4 +--
packages/client/ui-user-questions/README.md | 2 +-
.../client/ui-user-questions/README.zh.md | 2 +-
.../src/client/QuestionComposer.tsx | 5 ++--
.../ui-user-questions/src/client/locales.ts | 12 +++------
.../user-questions-composer.client.spec.tsx | 27 +++++++++----------
7 files changed, 29 insertions(+), 32 deletions(-)
diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts
index a120479c59..0861e3a8ba 100644
--- a/apps/web/tests/question-composer.e2e.ts
+++ b/apps/web/tests/question-composer.e2e.ts
@@ -622,7 +622,7 @@ describe('web e2e: resident question composer round trip', () => {
await composer.getByRole('heading', { name: '选择 HF provider 的思考等级?' }).waitFor()
expect(await composer.getByRole('heading').evaluate(readCopy))
.toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined })
- expect(await composer.getByRole('button', { name: 'Show the full question' }).count()).toBe(0)
+ expect(await composer.getByRole('button', { name: 'Expand', exact: true }).count()).toBe(0)
const rows = await composer.locator('[data-option-row]').evaluateAll(options => options.map((row) => {
const label = row.querySelector('[data-option-line] > span:first-child')
const description = row.querySelector('[data-option-description]')
@@ -758,10 +758,11 @@ describe('web e2e: resident question composer round trip', () => {
// scrollport while navigation and submission stay reachable.
const questionToggle = await toggleFor(heading)
expect(await questionToggle.getAttribute('aria-expanded')).toBe('false')
- expect(await questionToggle.getAttribute('aria-label')).toBe('Show the full question')
+ expect(await questionToggle.getAttribute('aria-label')).toBe('Expand')
+ expect(await questionToggle.getAttribute('aria-describedby')).toBe(await heading.getAttribute('id'))
await questionToggle.click()
expect(await questionToggle.getAttribute('aria-expanded')).toBe('true')
- expect(await questionToggle.getAttribute('aria-label')).toBe('Show less of the question')
+ expect(await questionToggle.getAttribute('aria-label')).toBe('Collapse')
const expandedQuestion = await heading.evaluate(readCopy)
expect(expandedQuestion).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' })
expect(expandedQuestion.lines).toBeGreaterThan(3)
@@ -784,7 +785,7 @@ describe('web e2e: resident question composer round trip', () => {
const descriptionId = await explicitText.getAttribute('id')
expect(await explicit.getAttribute('aria-describedby')).toBe(descriptionId)
expect(await descriptionToggle.getAttribute('aria-describedby')).toBe(descriptionId)
- expect(await descriptionToggle.getAttribute('aria-label')).toBe('Show the full description of “Explicit install”')
+ expect(await descriptionToggle.getAttribute('aria-label')).toBe('Expand')
await descriptionToggle.click()
expect(await explicit.getAttribute('aria-checked')).toBe('false')
const expandedDescription = await explicitText.evaluate(readCopy)
diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml
index 974ab9c64e..09db781665 100644
--- a/packages/client/ui-user-questions/README.i18n.yaml
+++ b/packages/client/ui-user-questions/README.i18n.yaml
@@ -18,8 +18,8 @@
en: 8d3815c19858ac85
zh: 94fc905fa202d334
/deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering:
- en: 2173107a23e9f92a
- zh: d3a6d17e50dd4b51
+ en: 244a344475118aa3
+ zh: 8ca447340de5d341
/deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back:
en: 10975f68ff9c6fcc
zh: 92246aadc363f0eb
diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md
index 9f2fe46110..d730c104df 100644
--- a/packages/client/ui-user-questions/README.md
+++ b/packages/client/ui-user-questions/README.md
@@ -29,7 +29,7 @@ When the agent asks a question, the attached card temporarily occupies the compo
### Answering
-A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A question appears in full up to three rendered lines; a longer one shows two, fades at the end of the second, and places a downward-chevron expand button there that expands it in place. Option descriptions use 13px text with a 20px line height and sit beside their label when they fit, otherwise below it; a description appears in full up to two lines, and a longer one shows one line with the same fade and expand button. Expanded copy keeps an upward-chevron collapse button on a line below it. Both buttons carry a localized accessible name and a hover tooltip. CSS scroll timelines decide which copy overflows; a browser without them shows all copy in full. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`.
+A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A question appears in full up to three rendered lines; a longer one shows two, fades at the end of the second, and places a downward-chevron expand button there that expands it in place. Option descriptions use 13px text with a 20px line height and sit beside their label when they fit, otherwise below it; a description appears in full up to two lines, and a longer one shows one line with the same fade and expand button. Expanded copy keeps an upward-chevron collapse button on a line below it. Both buttons are named **Expand** or **Collapse**, show that name in a hover tooltip, and are described by the text they control. CSS scroll timelines decide which copy overflows; a browser without them shows all copy in full. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`.
### Reading a settled question back
diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md
index 2ac78bf4c1..dba1010bec 100644
--- a/packages/client/ui-user-questions/README.zh.md
+++ b/packages/client/ui-user-questions/README.zh.md
@@ -29,7 +29,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户
### 作答
-用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。问题在实际排版不超过三行时完整显示;更长的问题只显示两行,第二行末尾渐隐并放置向下箭头的展开按钮,点击后原地展开。选项说明使用 13px 字号和 20px 行高,放得下时排在标签旁,否则排在标签下方;说明不超过两行时完整显示,更长的说明只显示一行,同样渐隐并放置展开按钮。展开后的文字下方单独一行显示向上箭头的收起按钮。两种按钮都有本地化的无障碍名称和悬停提示。由 CSS 滚动时间线判断文字是否溢出;不支持该特性的浏览器完整显示全部文字。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。
+用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。问题在实际排版不超过三行时完整显示;更长的问题只显示两行,第二行末尾渐隐并放置向下箭头的展开按钮,点击后原地展开。选项说明使用 13px 字号和 20px 行高,放得下时排在标签旁,否则排在标签下方;说明不超过两行时完整显示,更长的说明只显示一行,同样渐隐并放置展开按钮。展开后的文字下方单独一行显示向上箭头的收起按钮。两种按钮名为「展开」或「收起」,悬停时显示同样的提示,并以其控制的文字作为无障碍描述。由 CSS 滚动时间线判断文字是否溢出;不支持该特性的浏览器完整显示全部文字。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。
### 回看已结束的问题
diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx
index b8cdeae85b..5c00ccbf53 100644
--- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx
+++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx
@@ -250,7 +250,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
// CSS decides which copy is clipped; only the user's expansions are state.
const [expanded, setExpanded] = useState(NOTHING_EXPANDED)
- const questionToggleLabel = t(expanded.has('question') ? 'question.collapse' : 'question.expand')
+ const questionToggleLabel = t(expanded.has('question') ? 'text.collapse' : 'text.expand')
const toggleCopy = (copy: 'question' | number): void => {
setExpanded((current) => {
@@ -533,6 +533,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
aria-label={questionToggleLabel}
aria-expanded={expanded.has('question')}
aria-controls={`question-${pending.key}-${String(index)}`}
+ aria-describedby={`question-${pending.key}-${String(index)}`}
onClick={() => { toggleCopy('question') }}
>
{expanded.has('question') ? : }
@@ -548,7 +549,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
const display = parseRecommendedLabel(option.label)
const hasDescription = option.description !== undefined && option.description.trim() !== ''
const descriptionExpanded = expanded.has(optionIndex)
- const descriptionToggleLabel = t(descriptionExpanded ? 'option.collapse' : 'option.expand', { option: display.label })
+ const descriptionToggleLabel = t(descriptionExpanded ? 'text.collapse' : 'text.expand')
const descriptionId = `${descriptionIdPrefix}-question-${String(index)}-option-${String(optionIndex)}-description`
return (
{
// CSS shows each toggle only while its copy overflows; every candidate is rendered.
const heading = screen.getByRole('heading', { name: 'How should the release graph change?' })
- const questionToggle = screen.getByRole('button', { name: '展开完整问题' })
- const descriptionToggle = screen.getByRole('button', { name: '展开“Explicit install”的完整说明' })
- expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })).toEqual([questionToggle, descriptionToggle])
+ const [questionToggle, descriptionToggle, ...rest] = screen.getAllByRole('button', { name: '展开' })
+ expect(rest).toHaveLength(0)
+ if (questionToggle === undefined || descriptionToggle === undefined) throw new Error('Both copy toggles must render')
+ expect(questionToggle.getAttribute('aria-describedby')).toBe(heading.id)
expect(questionToggle.getAttribute('aria-controls')).toBe(heading.id)
const option = screen.getByRole('radio', { name: 'Explicit install' })
const text = screen.getByText(description)
@@ -328,7 +329,7 @@ describe('QuestionComposer', () => {
fireEvent.click(descriptionToggle)
expect(descriptionToggle.getAttribute('aria-expanded')).toBe('true')
- expect(descriptionToggle.getAttribute('aria-label')).toBe('收起“Explicit install”的说明')
+ expect(descriptionToggle.getAttribute('aria-label')).toBe('收起')
expect(text.closest('[data-option-row]')?.hasAttribute('data-expanded')).toBe(true)
expect(screen.getByText(description)).toBe(text)
expect(option.getAttribute('aria-checked')).toBe('false')
@@ -336,13 +337,13 @@ describe('QuestionComposer', () => {
fireEvent.click(questionToggle)
expect(questionToggle.getAttribute('aria-expanded')).toBe('true')
- expect(questionToggle.getAttribute('aria-label')).toBe('收起问题')
+ expect(questionToggle.getAttribute('aria-label')).toBe('收起')
expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(true)
fireEvent.click(questionToggle)
fireEvent.click(descriptionToggle)
expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(false)
expect(text.closest('[data-option-row]')?.hasAttribute('data-expanded')).toBe(false)
- expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })).toEqual([questionToggle, descriptionToggle])
+ expect(screen.getAllByRole('button', { name: '展开' })).toEqual([questionToggle, descriptionToggle])
})
it('resets scrolling and expanded copy when the question changes', () => {
@@ -363,9 +364,8 @@ describe('QuestionComposer', () => {
expect(screen.getByRole('region', { name: 'First question?' })).toBeTruthy()
const firstScroll = screen.getByText('First question?').closest('[data-question-scroll]') as HTMLElement
firstScroll.scrollTop = 80
- for (const toggle of screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })) fireEvent.click(toggle)
- expect(screen.getByRole('button', { name: '收起问题' }).getAttribute('aria-expanded')).toBe('true')
- expect(screen.getByRole('button', { name: '收起“Continue”的说明' }).getAttribute('aria-expanded')).toBe('true')
+ for (const toggle of screen.getAllByRole('button', { name: '展开' })) fireEvent.click(toggle)
+ expect(screen.getAllByRole('button', { name: '收起' }).map(toggle => toggle.getAttribute('aria-expanded'))).toEqual(['true', 'true'])
fireEvent.click(screen.getByRole('radio', { name: 'Continue' }))
const secondScroll = screen.getByText('Second question?').closest('[data-question-scroll]') as HTMLElement
@@ -373,12 +373,11 @@ describe('QuestionComposer', () => {
expect(screen.getByRole('region', { name: 'Second question?' })).toBeTruthy()
expect(secondScroll).toBe(firstScroll)
expect(secondScroll.scrollTop).toBe(0)
- expect(screen.queryByRole('button', { name: '收起问题' })).toBeNull()
- expect(screen.queryByRole('button', { name: '收起“Continue”的说明' })).toBeNull()
+ expect(screen.queryByRole('button', { name: '收起' })).toBeNull()
expect(secondScroll.querySelector('[data-expanded]')).toBeNull()
expect(screen.queryByText(firstDescription)).toBeNull()
- const descriptionToggle = screen.getByRole('button', { name: '展开“Continue”的完整说明' })
- expect(document.getElementById(descriptionToggle.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription)
+ const descriptionToggle = screen.getAllByRole('button', { name: '展开' })[1]
+ expect(document.getElementById(descriptionToggle?.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription)
})
it('renders plan detail through the shared assistant Markdown primitive', () => {
@@ -952,7 +951,7 @@ describe('review card', () => {
expect(chosen.getAttribute('aria-checked')).toBe('true')
expect(chosen.disabled).toBe(true)
// Long copy stays readable on a frozen card.
- expect(screen.getAllByRole
('button', { name: /^展开(完整问题|“)/ }).map(toggle => toggle.disabled)).toEqual([false, false, false])
+ expect(screen.getAllByRole('button', { name: '展开' }).map(toggle => toggle.disabled)).toEqual([false, false, false])
// Nothing is left to send, and the unused free-text field would read as
// somewhere to type.
expect(screen.queryByRole('button', { name: '跳过' })).toBeNull()
From fb290b388d92a73cb9ee189c8c44242bf8ec10fa Mon Sep 17 00:00:00 2001
From: yudshj
Date: Wed, 21 Oct 2026 12:02:02 +0800
Subject: [PATCH 26/43] fix(ui-user-questions): clip dropped lines from hit
testing
A collapsed question or description keeps its full line budget and only
masked its dropped line, so that transparent line still took clicks: it
covered the first line of the question detail and the top edge of the
custom answer row. clip-path now removes the line from hit testing as
well as from view, and Web e2e hit tests pin both targets.
Expansions also record the question they belong to and apply only while
that question is shown, so a position change from another surface of the
same Session no longer carries them to a different question. `.title`
returns to its base margin; `.questionTitle` owns the flex sizing.
---
apps/web/tests/question-composer.e2e.ts | 16 +++++++++++
.../src/client/QuestionComposer.module.css | 9 ++++---
.../src/client/QuestionComposer.tsx | 27 +++++++++++--------
.../question-composer-styles.client.spec.ts | 6 +++--
.../user-questions-composer.client.spec.tsx | 18 +++++++++++++
5 files changed, 59 insertions(+), 17 deletions(-)
diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts
index 0861e3a8ba..1e06d0cbb6 100644
--- a/apps/web/tests/question-composer.e2e.ts
+++ b/apps/web/tests/question-composer.e2e.ts
@@ -433,6 +433,15 @@ describe('web e2e: resident question composer round trip', () => {
const blueText = composer.locator('[data-option-description]').first()
const blueToggle = composer.locator(`button[aria-controls=${JSON.stringify(await blueText.getAttribute('id'))}]`)
await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
+ // A clipped description's dropped line takes no clicks: the custom row's top edge belongs to it.
+ expect(await composer.locator('[data-option-description]').last().evaluate(readCopy)).toMatchObject({ clipped: true })
+ expect(await composer.locator('textarea').evaluate((field) => {
+ const row = field.closest('[class*="customRow"]')
+ if (!(row instanceof HTMLElement)) return false
+ const box = row.getBoundingClientRect()
+ const hit = document.elementFromPoint(box.left + box.width / 2, box.top + 2)
+ return hit !== null && row.contains(hit)
+ })).toBe(true)
await blueToggle.click()
expect(await blue.getAttribute('aria-checked')).toBe('false')
expect(await blueToggle.getAttribute('aria-expanded')).toBe('true')
@@ -754,6 +763,13 @@ describe('web e2e: resident question composer round trip', () => {
expect(scrollStates).toContain(false)
expect(scrollStates).toContain(true)
+ // A clipped question's dropped line takes no clicks: the detail's first line belongs to the detail.
+ expect(await composer.getByText(detail, { exact: true }).evaluate((text) => {
+ const box = text.getBoundingClientRect()
+ const hit = document.elementFromPoint(box.left + 8, box.top + 3)
+ return hit !== null && text.contains(hit)
+ })).toBe(true)
+
// The question expands in place; its choices move down inside the
// scrollport while navigation and submission stay reachable.
const questionToggle = await toggleFor(heading)
diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css
index fa75de76e4..5c530aef72 100644
--- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css
+++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css
@@ -99,8 +99,7 @@
}
.title {
- flex-shrink: 0;
- margin: 5px 24px 0;
+ margin: 0;
font-size: 16px;
line-height: 22px;
font-weight: 500;
@@ -112,7 +111,8 @@
shows its expand button there. Each box keeps its full budget, plus a 2px
allowance for glyphs taller than the line, so the scroll timeline that
reports the overflow never depends on the collapsed layout. A negative
- bottom margin, not a smaller box, gives the dropped line back to the row. */
+ bottom margin, not a smaller box, gives the dropped line back to the row,
+ and clip-path removes that line from hit testing as well as from view. */
.questionTitle {
position: relative;
flex-shrink: 0;
@@ -142,6 +142,7 @@
@keyframes question-clipped {
from, to {
margin-bottom: calc(-1lh - 2px);
+ clip-path: inset(0 0 calc(1lh + 2px) 0);
mask-image: linear-gradient(#000, #000), linear-gradient(to right, #000 calc(100% - 56px), transparent calc(100% - 28px));
mask-size: 100% 1lh, 100% 1lh;
mask-position: 0 0, 0 1lh;
@@ -160,7 +161,6 @@
.cardMinimized .title {
display: -webkit-box;
- margin: 0;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
@@ -400,6 +400,7 @@
@keyframes description-clipped {
from, to {
margin-bottom: calc(-1lh - 2px);
+ clip-path: inset(0 0 calc(1lh + 2px) 0);
mask-image: linear-gradient(to right, #000 calc(100% - 56px), transparent calc(100% - 28px));
mask-size: 100% 1lh;
mask-repeat: no-repeat;
diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx
index 5c00ccbf53..0baf6e54c5 100644
--- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx
+++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx
@@ -27,10 +27,15 @@ const REMOVED_CARD: QuestionCardSnapshot = {
state: 'open', waitState: 'counting', countdown: undefined, channel: 'none', closed: true,
}
-/** Long copy the user expanded: the question itself or an option description by index. */
-type ExpandedCopy = ReadonlySet<'question' | number>
+/** Long copy the user expanded on one question: the question itself or an option description by index. */
+interface ExpandedCopy {
+ /** Index of the question the expansions belong to. */
+ readonly index: number
+ /** Expanded copy of that question: `'question'` or an option index. */
+ readonly copies: ReadonlySet<'question' | number>
+}
-const NOTHING_EXPANDED: ExpandedCopy = new Set()
+const NOTHING_EXPANDED: ExpandedCopy['copies'] = new Set()
/**
* Split the conventional recommendation suffix without changing the answer value.
@@ -247,24 +252,24 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
// oxlint-disable-next-line typescript/no-non-null-assertion
const draft = drafts[index]!
const hasOptions = (question.options?.length ?? 0) > 0
- // CSS decides which copy is clipped; only the user's expansions are state.
- const [expanded, setExpanded] = useState(NOTHING_EXPANDED)
+ // CSS decides which copy is clipped; only the user's expansions are state,
+ // and they apply only while their question is the one shown.
+ const [expandedCopy, setExpandedCopy] = useState({ index, copies: NOTHING_EXPANDED })
+ const expanded = expandedCopy.index === index ? expandedCopy.copies : NOTHING_EXPANDED
const questionToggleLabel = t(expanded.has('question') ? 'text.collapse' : 'text.expand')
const toggleCopy = (copy: 'question' | number): void => {
- setExpanded((current) => {
- const next = new Set(current)
- if (!next.delete(copy)) next.add(copy)
- return next
- })
+ const copies = new Set(expanded)
+ if (!copies.delete(copy)) copies.add(copy)
+ setExpandedCopy({ index, copies })
}
const replaceProgress = (nextIndex: number, nextDrafts: QuestionDraftAnswer[]): void => {
if (nextIndex !== index) {
// Another question starts at its top with its long copy collapsed.
if (answerSurface.current !== null) answerSurface.current.scrollTop = 0
- setExpanded(NOTHING_EXPANDED)
+ setExpandedCopy({ index: nextIndex, copies: NOTHING_EXPANDED })
}
actions.replace(pending.key, {
index: nextIndex,
diff --git a/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts
index 4774ce8e67..7778302296 100644
--- a/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts
+++ b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts
@@ -28,7 +28,7 @@ function keyframes(name: string): string[] {
describe('QuestionComposer long-copy layout', () => {
it('reads question overflow against three lines and keeps two once it overflows', () => {
- expect(declarations('.questionTitle')).toContain('timeline-scope: --question-copy')
+ expect(declarations('.questionTitle')).toEqual(expect.arrayContaining(['flex-shrink: 0', 'timeline-scope: --question-copy']))
expect(declarations('.titlePreview')).toEqual(expect.arrayContaining([
'max-height: calc(3lh + 2px)',
'margin: 0 0 -2px',
@@ -41,6 +41,7 @@ describe('QuestionComposer long-copy layout', () => {
]))
expect(keyframes('question-clipped')).toEqual(expect.arrayContaining([
'margin-bottom: calc(-1lh - 2px)',
+ 'clip-path: inset(0 0 calc(1lh + 2px) 0)',
'mask-size: 100% 1lh, 100% 1lh',
'mask-position: 0 0, 0 1lh',
]))
@@ -69,6 +70,7 @@ describe('QuestionComposer long-copy layout', () => {
]))
expect(keyframes('description-clipped')).toEqual(expect.arrayContaining([
'margin-bottom: calc(-1lh - 2px)',
+ 'clip-path: inset(0 0 calc(1lh + 2px) 0)',
'mask-size: 100% 1lh',
]))
expect(declarations('.descriptionToggle')).toEqual(expect.arrayContaining([
@@ -104,6 +106,6 @@ describe('QuestionComposer long-copy layout', () => {
'min-height: 0',
'overflow-y: auto',
]))
- expect(declarations('.title')).toContain('flex-shrink: 0')
+ expect(declarations('.questionTitle')).toContain('flex-shrink: 0')
})
})
diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx
index 86650560fb..dfa9541320 100644
--- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx
+++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx
@@ -380,6 +380,24 @@ describe('QuestionComposer', () => {
expect(document.getElementById(descriptionToggle?.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription)
})
+ it('keeps expansions with their question when the shared position moves elsewhere', () => {
+ const { carrier } = wait([
+ { id: 'first', question: 'First question?', options: [{ label: 'Continue', description: 'First detail.' }] },
+ { id: 'second', question: 'Second question?', options: [{ label: 'Continue', description: 'Second detail.' }] },
+ ])
+ render()
+ fireEvent.click(screen.getAllByRole('button', { name: '展开' })[1] as HTMLElement)
+ expect(screen.getByRole('button', { name: '收起' })).toBeTruthy()
+
+ // Another surface of the same Session moves the shared question position.
+ const blank = { selected: [], custom: '', skipped: false }
+ act(() => { kit.actions.replace(carrier.key, { index: 1, drafts: [blank, blank] }) })
+
+ expect(screen.getByRole('region', { name: 'Second question?' })).toBeTruthy()
+ expect(screen.queryByRole('button', { name: '收起' })).toBeNull()
+ expect(document.querySelector('[data-expanded]')).toBeNull()
+ })
+
it('renders plan detail through the shared assistant Markdown primitive', () => {
const { carrier } = wait([{
id: 'plan',
From d12498798aaf223f0e6552be6324c870ea3afe44 Mon Sep 17 00:00:00 2001
From: yudshj
Date: Wed, 21 Oct 2026 16:26:54 +0800
Subject: [PATCH 27/43] fix(conversation): add missing Japanese quote removal
label
The Japanese dictionary landed without the quote removal copy its English and Chinese dictionaries already carry, so the conversation dictionaries do not typecheck against the current master.
---
packages/client/ui-conversation/src/client/locales.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts
index f908a94233..36edb38e56 100644
--- a/packages/client/ui-conversation/src/client/locales.ts
+++ b/packages/client/ui-conversation/src/client/locales.ts
@@ -772,6 +772,7 @@ export const en = {
/** Japanese copy, checked against the English keys. */
export const ja = {
+ 'quote.remove': '引用を削除',
'shortcut.newline': '改行',
'shortcut.complementary': '補助のキュー / 実行中の指示変更を使用',
'shortcut.slash': 'コマンドメニューを開く',
From cf620a98df920b747f858ff3e785f4f16a0cba94 Mon Sep 17 00:00:00 2001
From: yudshj
Date: Wed, 21 Oct 2026 16:35:29 +0800
Subject: [PATCH 28/43] fix(message-quotes): register the Japanese dictionary
The quote plugin's dictionaries predate the Japanese locale merge, so the locale dictionary parity gate rejects its object-form dictionary without Japanese copy.
---
packages/session/message-quotes/src/client/index.ts | 4 ++--
packages/session/message-quotes/src/client/locales.ts | 5 +++++
2 files changed, 7 insertions(+), 2 deletions(-)
diff --git a/packages/session/message-quotes/src/client/index.ts b/packages/session/message-quotes/src/client/index.ts
index 09e352267e..b0748db925 100644
--- a/packages/session/message-quotes/src/client/index.ts
+++ b/packages/session/message-quotes/src/client/index.ts
@@ -8,7 +8,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import { QuoteSelectionAction } from './QuoteSelection.tsx'
import { QuotedUserText } from './QuotedUserText.tsx'
import { quoteRule } from './quote-rule.ts'
-import { en, NS, zh, type MessageQuotesKey } from './locales.ts'
+import { en, ja, NS, zh, type MessageQuotesKey } from './locales.ts'
export type {} from './quote-rule.ts'
@@ -30,7 +30,7 @@ export const inject = ['slots', 'locale', 'sessions', 'uiConversation']
* @param ctx - Client root context owning every Quote contribution.
*/
export function apply(ctx: Context): void {
- ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'message-quotes: dictionaries')
+ ctx.effect(() => ctx.locale.register(NS, { zh, en, ja }), 'message-quotes: dictionaries')
ctx.effect(() => ctx.uiConversation.userText.register(quoteRule), 'message-quotes: UserText rule')
ctx.effect(() => ctx.uiConversation.interactions.register({
id: 'quote',
diff --git a/packages/session/message-quotes/src/client/locales.ts b/packages/session/message-quotes/src/client/locales.ts
index c36e0d3599..fab1fbc782 100644
--- a/packages/session/message-quotes/src/client/locales.ts
+++ b/packages/session/message-quotes/src/client/locales.ts
@@ -15,3 +15,8 @@ export type MessageQuotesKey = keyof typeof zh
export const en: Record = {
'quote.add': 'Quote text',
}
+
+/** Japanese dictionary. */
+export const ja = {
+ 'quote.add': 'テキストを引用',
+} satisfies Record
From c47d3285b5f7f2394438502dba4606e90ac42d6a Mon Sep 17 00:00:00 2001
From: yudshj
Date: Wed, 21 Oct 2026 16:50:06 +0800
Subject: [PATCH 29/43] test(snapshots): record the current present tool
description
The background-wait-steer pinned tool schemas still carry the pre-reword present description, so the snapshot header no longer matches the shipped tool.
---
snapshots/sdk/background-wait-steer/tool-schemas.expected.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/snapshots/sdk/background-wait-steer/tool-schemas.expected.json b/snapshots/sdk/background-wait-steer/tool-schemas.expected.json
index 90228febab..c9fb03068a 100644
--- a/snapshots/sdk/background-wait-steer/tool-schemas.expected.json
+++ b/snapshots/sdk/background-wait-steer/tool-schemas.expected.json
@@ -325,7 +325,7 @@
},
{
"name": "present",
- "description": "Declare existing files as final deliverables for the user. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied.",
+ "description": "Declare existing files as final deliverables for the user. Hosts may show presented files only after your turn ends, so call it once the files are final. If the user needs to review files earlier, such as before a tool call that waits for their answer, link them in your message instead. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied.",
"parameters": {
"type": "object",
"properties": {
From 957379e8a908b7fd2e842406f16b2980ebafcdfc Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Wed, 21 Oct 2026 17:18:51 +0800
Subject: [PATCH 30/43] test(session-log-export): adapt merged input actions
Add the master-introduced insertQuote input action to the isolated Header action fixture so the merged client aggregate typechecks.
---
.../session-log-export/tests/header-action.client.spec.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx
index 86a35d5e04..dc085293d5 100644
--- a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx
+++ b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx
@@ -26,7 +26,7 @@ const standard: GlobalStandardProps & SessionStandardProps = {
useSessionAccess: () => true, useRemoteAccess: () => true, useWorkspaceAccess: () => true,
useConversation: unused, useInput: unused, useChat: unused, useTrajectory: unused,
usePanelInfo: unused, useResource: unused, useWorkspaces: unused,
- inputActions: { captureInsertion: unused, insertText: unused, setDraft: unused, persistDraft: unused,
+ inputActions: { insertQuote: () => false, captureInsertion: unused, insertText: unused, setDraft: unused, persistDraft: unused,
addAttachments: unused, removeAttachment: unused, pruneAttachments: unused, submit: unused },
}
From 0ab1fcb6747af599f31d4de171364761633e00e2 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Wed, 21 Oct 2026 17:18:56 +0800
Subject: [PATCH 31/43] fix(session-log-export): clarify Session Markdown copy
label
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Rename the Header menu item to Copy Session as Markdown (复制会话为 Markdown) so it states that the whole selected Session is copied. Update Header selectors, browser coverage, controller JSDoc, and the bilingual README; success toast wording stays unchanged.
---
apps/web/tests/navigation-panes.e2e.ts | 4 ++--
.../session-log-export/README.i18n.yaml | 14 +++++++-------
.../session-query/session-log-export/README.md | 8 ++++----
.../session-log-export/README.zh.md | 8 ++++----
.../src/client/copy-controller.ts | 2 +-
.../session-log-export/src/client/locales.ts | 4 ++--
.../tests/header-action.client.spec.tsx | 17 +++++++++++------
7 files changed, 31 insertions(+), 26 deletions(-)
diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts
index bce1dd19a4..ae8726c23f 100644
--- a/apps/web/tests/navigation-panes.e2e.ts
+++ b/apps/web/tests/navigation-panes.e2e.ts
@@ -448,7 +448,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
&& new URL(response.url()).pathname === '/api/session.markdown', { timeout: 30_000 })
const copyButton = page.getByRole('button', { name: 'More actions' })
await copyButton.click()
- await page.getByRole('menuitem', { name: 'Copy as Markdown' }).click()
+ await page.getByRole('menuitem', { name: 'Copy Session as Markdown' }).click()
const response = await markdownResponse
expect(response.status()).toBe(200)
expect(response.headers()['content-type']).toBe('text/markdown; charset=utf-8')
@@ -481,7 +481,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
expect(normalized.endsWith('\n\n')).toBe(false)
await copyButton.click()
- expect(await page.getByRole('menuitem', { name: 'Copy as Markdown' }).isDisabled()).toBe(false)
+ expect(await page.getByRole('menuitem', { name: 'Copy Session as Markdown' }).isDisabled()).toBe(false)
await page.keyboard.press('Escape')
}, 60_000)
diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml
index 5fe8787069..b4cbc02951 100644
--- a/packages/session-query/session-log-export/README.i18n.yaml
+++ b/packages/session-query/session-log-export/README.i18n.yaml
@@ -9,17 +9,17 @@
en: eea87f5da57ff494
zh: db388203522b840f
/deepseek-ai-dsh-session-log-export/summary:
- en: 9268b3aaeaf82673
- zh: 99cc5b0de0b5a8bd
+ en: ee7d348dd7fe684f
+ zh: 6cd6d5d644176355
/deepseek-ai-dsh-session-log-export/table-of-contents:
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-session-log-export/use-this-package:
- en: f37f81af3601784d
- zh: 89b47be0660c8b8b
-/deepseek-ai-dsh-session-log-export/use-this-package/copy-as-markdown:
- en: 59ba15abb59b6bd0
- zh: 1ea2b05fc05b9e61
+ en: bb12bdeafa8571b5
+ zh: e7df75c03478f7ed
+/deepseek-ai-dsh-session-log-export/use-this-package/copy-session-as-markdown:
+ en: 55c163a4ae5b8d5d
+ zh: c1dac154ed1fa56d
/deepseek-ai-dsh-session-log-export/use-this-package/when-to-choose-it:
en: 4011f686afd24f09
zh: 2710b9995c80ba1f
diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md
index 31416a2731..58cf4006df 100644
--- a/packages/session-query/session-log-export/README.md
+++ b/packages/session-query/session-log-export/README.md
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
-`dsh-session-log-export` lets the Web interface export a session's complete history. The Session Header's more-actions menu offers `Copy as Markdown` and `Download session log`; an `/export` slash command also starts the ZIP download. The ZIP contains the session, its sub-sessions, and attachments. The package owns both authenticated Host routes, the Markdown transcript formatter, and the browser controls and feedback. The browser chooses the download destination and owns the clipboard. Setup and usage come first; implementation details follow.
+`dsh-session-log-export` lets the Web interface export a session's complete history. The Session Header's more-actions menu offers `Copy Session as Markdown` and `Download session log`; an `/export` slash command also starts the ZIP download. The ZIP contains the session, its sub-sessions, and attachments. The package owns both authenticated Host routes, the Markdown transcript formatter, and the browser controls and feedback. The browser chooses the download destination and owns the clipboard. Setup and usage come first; implementation details follow.
## Table of Contents
@@ -25,11 +25,11 @@ English | [中文](README.zh.md)
## Use this package
-Use this package when the Web bundle should let users export a session log. It requires Connection, the command registry, Session query and persistence, and attachments. Mount the plugin, then choose `Copy as Markdown` or `Download session log` from the Session Header's more-actions menu, or type `/export`; the browser copies the transcript or downloads `dsh-session-.zip`.
+Use this package when the Web bundle should let users export a session log. It requires Connection, the command registry, Session query and persistence, and attachments. Mount the plugin, then choose `Copy Session as Markdown` or `Download session log` from the Session Header's more-actions menu, or type `/export`; the browser copies the transcript or downloads `dsh-session-.zip`.
-### Copy as Markdown
+### Copy Session as Markdown
-`Copy as Markdown` copies the complete selected Session, including history that the client has not loaded, as portable Markdown. The transcript starts with the latest log-backed title. Human user messages are blockquotes in chronological order, final assistant replies remain ordinary Markdown, and earlier assistant commentary and tool activity in each turn are folded into a collapsible `` section before that turn's final reply. A turn whose last committed assistant message carries tool calls has no final reply, so all of its activity stays folded. An incomplete turn renders the committed messages it has. Only append message events are exported. An unfinished assistant code fence is closed at the message boundary, and four-space or tab-indented code keeps its indentation.
+`Copy Session as Markdown` copies the complete selected Session, including history that the client has not loaded, as portable Markdown. The transcript starts with the latest log-backed title. Human user messages are blockquotes in chronological order, final assistant replies remain ordinary Markdown, and earlier assistant commentary and tool activity in each turn are folded into a collapsible `` section before that turn's final reply. A turn whose last committed assistant message carries tool calls has no final reply, so all of its activity stays folded. An incomplete turn renders the committed messages it has. Only append message events are exported. An unfinished assistant code fence is closed at the message boundary, and four-space or tab-indented code keeps its indentation.
The formatter reads the committed raw log, so a compaction surface replacement never removes earlier messages, and fork-inherited history appears once. The title and body come from one `readSession` snapshot. When persistence metadata reports a selected materialized artifact, the transcript adds a `Session file:` fenced text block with that exact path; without one, the block is absent. Only the selected Session and its inherited prefix are copied; descendant logs remain ZIP-only. Reasoning blocks, system and developer messages, injected user-role context whose source kind is not `user`, request headers and context, and runtime records are omitted. Images and files become readable labels; attachment bytes and authentication links never enter the transcript. Structural labels such as `Assistant`, `Tool`, and attachment placeholders are intentionally English; original message text stays unchanged. The text ends with exactly one newline.
diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md
index 67b94237e2..01df07877f 100644
--- a/packages/session-query/session-log-export/README.zh.md
+++ b/packages/session-query/session-log-export/README.zh.md
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
-`dsh-session-log-export` 让 Web 界面导出会话的完整历史。Session Header 的更多操作菜单提供 `复制为 Markdown` 与 `下载 Session 日志`;`/export` 斜杠命令也会启动 ZIP 下载。ZIP 包含会话本身、其子会话与附件。本包拥有两条经过认证的 Host 路由、Markdown 记录格式化器以及浏览器控件和反馈。下载目标位置由浏览器选择,剪贴板由浏览器持有。设置与用法在前,随后说明实现细节。
+`dsh-session-log-export` 让 Web 界面导出会话的完整历史。Session Header 的更多操作菜单提供 `复制会话为 Markdown` 与 `下载 Session 日志`;`/export` 斜杠命令也会启动 ZIP 下载。ZIP 包含会话本身、其子会话与附件。本包拥有两条经过认证的 Host 路由、Markdown 记录格式化器以及浏览器控件和反馈。下载目标位置由浏览器选择,剪贴板由浏览器持有。设置与用法在前,随后说明实现细节。
## 目录
@@ -25,11 +25,11 @@ kind: "package-reference"
## 使用本包
-当 Web bundle 需要让用户导出会话日志时使用本包。它需要 Connection、命令注册表、Session 查询与持久化以及附件服务。挂载插件,然后在 Session Header 的更多操作菜单中选择 `复制为 Markdown` 或 `下载 Session 日志`,或输入 `/export`;浏览器会复制记录或下载 `dsh-session-.zip`。
+当 Web bundle 需要让用户导出会话日志时使用本包。它需要 Connection、命令注册表、Session 查询与持久化以及附件服务。挂载插件,然后在 Session Header 的更多操作菜单中选择 `复制会话为 Markdown` 或 `下载 Session 日志`,或输入 `/export`;浏览器会复制记录或下载 `dsh-session-.zip`。
-### 复制为 Markdown
+### 复制会话为 Markdown
-`复制为 Markdown` 会把选中的完整 Session 复制为可移植 Markdown,包括客户端尚未加载的历史。记录以最新的日志标题开头。人类用户消息按时间顺序作为引用块,最终的 assistant 回复保持普通 Markdown,每轮中较早的 assistant 说明与工具活动折叠到该轮最终回复之前的 `` 区段。若一轮中最后一条已提交 assistant 消息带有工具调用,该轮没有最终回复,全部活动保持折叠。未完成的一轮渲染其已提交的消息。仅导出 append 消息事件。未完成的 assistant 代码围栏会在消息边界闭合,四空格或制表符缩进的代码保留缩进。
+`复制会话为 Markdown` 会把选中的完整 Session 复制为可移植 Markdown,包括客户端尚未加载的历史。记录以最新的日志标题开头。人类用户消息按时间顺序作为引用块,最终的 assistant 回复保持普通 Markdown,每轮中较早的 assistant 说明与工具活动折叠到该轮最终回复之前的 `` 区段。若一轮中最后一条已提交 assistant 消息带有工具调用,该轮没有最终回复,全部活动保持折叠。未完成的一轮渲染其已提交的消息。仅导出 append 消息事件。未完成的 assistant 代码围栏会在消息边界闭合,四空格或制表符缩进的代码保留缩进。
格式化器读取已提交的原始日志,因此压缩产生的表层替换不会移除较早消息,fork 继承的历史只出现一次。标题与正文来自同一个 `readSession` 快照。持久化元数据报告选中的已物化产物时,记录会在标题后添加一个 `Session file:` 围栏文本块,包含该精确路径;没有时省略该块。只复制选中的 Session 及其继承前缀;子会话日志仍只由 ZIP 导出。推理块、system 与 developer 消息、source kind 不是 `user` 的注入用户角色上下文、请求头与上下文以及运行时记录都会被省略。图片与文件变成可读标签;附件字节与认证链接不会进入记录。`Assistant`、`Tool` 和附件占位符等结构性标签有意使用英文;原始消息文本保持不变。文本以恰好一个换行结尾。
diff --git a/packages/session-query/session-log-export/src/client/copy-controller.ts b/packages/session-query/session-log-export/src/client/copy-controller.ts
index 50eeb0d1cb..7f4baa2a04 100644
--- a/packages/session-query/session-log-export/src/client/copy-controller.ts
+++ b/packages/session-query/session-log-export/src/client/copy-controller.ts
@@ -1,5 +1,5 @@
/**
- * Browser controller for the Session Header's "Copy as Markdown" action. One
+ * Browser controller for the Session Header's "Copy Session as Markdown" action. One
* plugin instance owns one pending copy: the clipboard is a browser-wide
* destination, so a second request joins the pending operation instead of
* racing it.
diff --git a/packages/session-query/session-log-export/src/client/locales.ts b/packages/session-query/session-log-export/src/client/locales.ts
index 36dbf042fe..b3bb8ba6e2 100644
--- a/packages/session-query/session-log-export/src/client/locales.ts
+++ b/packages/session-query/session-log-export/src/client/locales.ts
@@ -4,7 +4,7 @@ export const NS = 'session-log-download'
/** Simplified-Chinese Session export strings. */
export const zh = {
'header.more': '更多操作',
- 'menu.copyMarkdown': '复制为 Markdown',
+ 'menu.copyMarkdown': '复制会话为 Markdown',
'menu.download': '下载 Session 日志',
'menu.feedback': '反馈',
'toast.copied': 'Session 已复制为 Markdown',
@@ -22,7 +22,7 @@ export const zh = {
/** English Session export strings. */
export const en: Record = {
'header.more': 'More actions',
- 'menu.copyMarkdown': 'Copy as Markdown',
+ 'menu.copyMarkdown': 'Copy Session as Markdown',
'menu.download': 'Download session log',
'menu.feedback': 'Feedback',
'toast.copied': 'Session copied as Markdown.',
diff --git a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx
index dc085293d5..462064fbad 100644
--- a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx
+++ b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx
@@ -9,7 +9,7 @@ import { SessionLogDownloadController } from '../src/client/controller.ts'
import { SessionMarkdownCopyController, type SessionMarkdownClipboard } from '../src/client/copy-controller.ts'
import { SessionLogDownloadHeaderAction } from '../src/client/HeaderAction.tsx'
import type { SessionLogDownloadHeaderProps } from '../src/client/HeaderAction.tsx'
-import { en } from '../src/client/locales.ts'
+import { en, zh } from '../src/client/locales.ts'
const SID = 'session-export-header' as SessionId
const clipboard: SessionMarkdownClipboard = {
@@ -69,6 +69,11 @@ function bench(feedbackAvailable = false, copyController = new SessionMarkdownCo
afterEach(cleanup)
describe('Session export Header action', () => {
+ it('names whole-Session copying in English and Chinese', () => {
+ expect(en['menu.copyMarkdown']).toBe('Copy Session as Markdown')
+ expect(zh['menu.copyMarkdown']).toBe('复制会话为 Markdown')
+ })
+
it('opens Session feedback and closes the menu without starting a download', () => {
const b = bench(true)
fireEvent.click(b.view.getByRole('button', { name: 'More actions' }))
@@ -82,7 +87,7 @@ describe('Session export Header action', () => {
const b = bench()
fireEvent.click(b.view.getByRole('button', { name: 'More actions' }))
expect(b.view.queryByRole('menuitem', { name: 'Feedback' })).toBeNull()
- expect(b.view.getByRole('menuitem', { name: 'Copy as Markdown' })).toBeTruthy()
+ expect(b.view.getByRole('menuitem', { name: 'Copy Session as Markdown' })).toBeTruthy()
expect(b.view.getByRole('menuitem', { name: 'Download session log' })).toBeTruthy()
})
@@ -108,10 +113,10 @@ describe('Session export Header action', () => {
const b = bench()
fireEvent.click(b.view.getByRole('button', { name: 'More actions' }))
expect(b.view.getAllByRole('menuitem').map(item => item.textContent)).toEqual([
- 'Copy as Markdown',
+ 'Copy Session as Markdown',
'Download session log',
])
- fireEvent.click(b.view.getByRole('menuitem', { name: 'Copy as Markdown' }))
+ fireEvent.click(b.view.getByRole('menuitem', { name: 'Copy Session as Markdown' }))
await waitFor(() => { expect(b.copyMarkdown).toHaveBeenCalledWith(SID) })
expect(b.request).not.toHaveBeenCalled()
expect(b.view.queryByRole('menu')).toBeNull()
@@ -128,7 +133,7 @@ describe('Session export Header action', () => {
const button = b.view.getByRole('button', { name: 'More actions' })
await waitFor(() => { expect(button.getAttribute('aria-expanded')).toBe('false') })
fireEvent.click(button)
- const copyItem = b.view.getByRole('menuitem', { name: 'Copy as Markdown' })
+ const copyItem = b.view.getByRole('menuitem', { name: 'Copy Session as Markdown' })
await waitFor(() => { expect((copyItem as HTMLButtonElement).disabled).toBe(true) })
expect((b.view.getByRole('menuitem', { name: 'Download session log' }) as HTMLButtonElement).disabled).toBe(false)
expect((b.view.getByRole('menuitem', { name: 'Feedback' }) as HTMLButtonElement).disabled).toBe(false)
@@ -188,7 +193,7 @@ describe('Session export Header action', () => {
fireEvent.click(button)
const item = b.view.getByRole('menuitem', { name: 'Download session log' })
expect((item as HTMLButtonElement).disabled).toBe(true)
- expect((b.view.getByRole('menuitem', { name: 'Copy as Markdown' }) as HTMLButtonElement).disabled).toBe(false)
+ expect((b.view.getByRole('menuitem', { name: 'Copy Session as Markdown' }) as HTMLButtonElement).disabled).toBe(false)
expect((b.view.getByRole('menuitem', { name: 'Feedback' }) as HTMLButtonElement).disabled).toBe(false)
release(new Response('zip'))
await download
From e4e1411a5cb77fcb59838ab94f7b01de6dfd5388 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Wed, 21 Oct 2026 17:24:56 +0800
Subject: [PATCH 32/43] test(session-log-export): remove redundant label
assertion
Keep the rendered Header and browser scenarios as the copy-action coverage, and describe the backend converter as used by the Session Markdown copy Host route.
---
packages/session-query/session-log-export/src/markdown.ts | 6 +++---
.../session-log-export/tests/header-action.client.spec.tsx | 7 +------
2 files changed, 4 insertions(+), 9 deletions(-)
diff --git a/packages/session-query/session-log-export/src/markdown.ts b/packages/session-query/session-log-export/src/markdown.ts
index 84261bee39..4ebd1d6be4 100644
--- a/packages/session-query/session-log-export/src/markdown.ts
+++ b/packages/session-query/session-log-export/src/markdown.ts
@@ -1,7 +1,7 @@
/**
- * Pure backend converter for one complete committed Session log. The "Copy as
- * Markdown" Host route uses it, and the package entry exports it for callers
- * that already hold the logical events. The raw log is authoritative:
+ * Pure backend converter for one complete committed Session log. The Session
+ * Markdown copy Host route uses it, and the package entry exports it for
+ * callers that already hold the logical events. The raw log is authoritative:
* compaction surface replacements never remove earlier messages, and
* fork-inherited history appears once. Reasoning, system and developer
* messages, injected non-human context, request headers, and runtime records
diff --git a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx
index 462064fbad..a8e6321b1a 100644
--- a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx
+++ b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx
@@ -9,7 +9,7 @@ import { SessionLogDownloadController } from '../src/client/controller.ts'
import { SessionMarkdownCopyController, type SessionMarkdownClipboard } from '../src/client/copy-controller.ts'
import { SessionLogDownloadHeaderAction } from '../src/client/HeaderAction.tsx'
import type { SessionLogDownloadHeaderProps } from '../src/client/HeaderAction.tsx'
-import { en, zh } from '../src/client/locales.ts'
+import { en } from '../src/client/locales.ts'
const SID = 'session-export-header' as SessionId
const clipboard: SessionMarkdownClipboard = {
@@ -69,11 +69,6 @@ function bench(feedbackAvailable = false, copyController = new SessionMarkdownCo
afterEach(cleanup)
describe('Session export Header action', () => {
- it('names whole-Session copying in English and Chinese', () => {
- expect(en['menu.copyMarkdown']).toBe('Copy Session as Markdown')
- expect(zh['menu.copyMarkdown']).toBe('复制会话为 Markdown')
- })
-
it('opens Session feedback and closes the menu without starting a download', () => {
const b = bench(true)
fireEvent.click(b.view.getByRole('button', { name: 'More actions' }))
From af4360620e03cd4f6116a31177e2b91d94ca6772 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Wed, 21 Oct 2026 17:41:05 +0800
Subject: [PATCH 33/43] test(snapshots): align background wait present schema
The SDK background-wait-steer fixture was authored on a branch that predates master's present description change (1ec651f2098). Refresh only its expected present tool description; committed Session generations and product behavior stay unchanged.
---
snapshots/sdk/background-wait-steer/tool-schemas.expected.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/snapshots/sdk/background-wait-steer/tool-schemas.expected.json b/snapshots/sdk/background-wait-steer/tool-schemas.expected.json
index 90228febab..c9fb03068a 100644
--- a/snapshots/sdk/background-wait-steer/tool-schemas.expected.json
+++ b/snapshots/sdk/background-wait-steer/tool-schemas.expected.json
@@ -325,7 +325,7 @@
},
{
"name": "present",
- "description": "Declare existing files as final deliverables for the user. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied.",
+ "description": "Declare existing files as final deliverables for the user. Hosts may show presented files only after your turn ends, so call it once the files are final. If the user needs to review files earlier, such as before a tool call that waits for their answer, link them in your message instead. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied.",
"parameters": {
"type": "object",
"properties": {
From c7278aa4d5483a4d41bcc6c606cd77436435b1a6 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Wed, 21 Oct 2026 17:46:00 +0800
Subject: [PATCH 34/43] fix(message-quotes): complete Japanese locale
registration
Add the Japanese quote action dictionary and register it with the existing Chinese and English dictionaries. The package entered master alongside the Japanese locale lane without a Japanese entry.
---
packages/session/message-quotes/src/client/index.ts | 4 ++--
packages/session/message-quotes/src/client/locales.ts | 5 +++++
2 files changed, 7 insertions(+), 2 deletions(-)
diff --git a/packages/session/message-quotes/src/client/index.ts b/packages/session/message-quotes/src/client/index.ts
index 09e352267e..b0748db925 100644
--- a/packages/session/message-quotes/src/client/index.ts
+++ b/packages/session/message-quotes/src/client/index.ts
@@ -8,7 +8,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import { QuoteSelectionAction } from './QuoteSelection.tsx'
import { QuotedUserText } from './QuotedUserText.tsx'
import { quoteRule } from './quote-rule.ts'
-import { en, NS, zh, type MessageQuotesKey } from './locales.ts'
+import { en, ja, NS, zh, type MessageQuotesKey } from './locales.ts'
export type {} from './quote-rule.ts'
@@ -30,7 +30,7 @@ export const inject = ['slots', 'locale', 'sessions', 'uiConversation']
* @param ctx - Client root context owning every Quote contribution.
*/
export function apply(ctx: Context): void {
- ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'message-quotes: dictionaries')
+ ctx.effect(() => ctx.locale.register(NS, { zh, en, ja }), 'message-quotes: dictionaries')
ctx.effect(() => ctx.uiConversation.userText.register(quoteRule), 'message-quotes: UserText rule')
ctx.effect(() => ctx.uiConversation.interactions.register({
id: 'quote',
diff --git a/packages/session/message-quotes/src/client/locales.ts b/packages/session/message-quotes/src/client/locales.ts
index c36e0d3599..fab1fbc782 100644
--- a/packages/session/message-quotes/src/client/locales.ts
+++ b/packages/session/message-quotes/src/client/locales.ts
@@ -15,3 +15,8 @@ export type MessageQuotesKey = keyof typeof zh
export const en: Record = {
'quote.add': 'Quote text',
}
+
+/** Japanese dictionary. */
+export const ja = {
+ 'quote.add': 'テキストを引用',
+} satisfies Record
From 09899f3a0fd7de180cd05666b2d4c8b9d4dae173 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Wed, 21 Oct 2026 11:39:05 +0800
Subject: [PATCH 35/43] =?UTF-8?q?feat(remote-access):=20=E6=8F=92=E4=BB=B6?=
=?UTF-8?q?=E6=B3=A8=E5=86=8C=E8=B7=9F=E9=9A=8F=E8=AE=BF=E9=97=AE=E7=8A=B6?=
=?UTF-8?q?=E6=80=81=EF=BC=88whenAccess=EF=BC=89=EF=BC=8Cindex=20=E6=8C=89?=
=?UTF-8?q?=20Peer=20=E6=B3=A8=E5=85=A5=E8=AE=BF=E9=97=AE=E5=BF=AB?=
=?UTF-8?q?=E7=85=A7?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- remote-access 新增 whenAccess / whenResourceAccess,ui-session、ui-workspace 各自提供 whenAccess
- schedule、plugin-manager、settings-general、settings-account、directory-picker-native 的入口包进 global whenAccess;消息反馈按钮按会话 useAccess 渲染
- webserver/index-inject 带 Peer,renderIndex / collectIndexInjections 需要 Peer,remote-access 注入 snapshotFor(peer.principal)
- client-test-runtime 新增 StaticRemoteAccess、staticAccessPlugin 与 TestClientOptions.access
- implemented Agent Note 2026-10-21-when-access 与升级指南 webserver-index-peer
Refs #2940
---
...peer-principal-and-remote-access.i18n.yaml | 8 +-
...-10-12-peer-principal-and-remote-access.md | 4 +-
...-12-peer-principal-and-remote-access.zh.md | 4 +-
.../2026-10-21-when-access.i18n.yaml | 28 ++
.../architecture/2026-10-21-when-access.md | 88 +++++
.../architecture/2026-10-21-when-access.zh.md | 88 +++++
apps/desktop-host/src/index.ts | 2 +-
docs/config-catalog.md | 2 +-
docs/config-catalog.zh.md | 2 +-
docs/event-producer-consumer.md | 2 +-
docs/event-producer-consumer.zh.md | 2 +-
docs/subsystems/typert.md | 7 +-
docs/subsystems/typert.zh.md | 7 +-
docs/subsystems/web-server.md | 29 +-
docs/subsystems/web-server.zh.md | 29 +-
.../webserver-index-peer/guide.i18n.yaml | 16 +
.../v0.2.1-rc.2/webserver-index-peer/guide.md | 17 +
.../webserver-index-peer/guide.zh.md | 17 +
packages/api/remote-access/README.i18n.yaml | 8 +-
packages/api/remote-access/README.md | 5 +-
packages/api/remote-access/README.zh.md | 5 +-
.../api/remote-access/src/client/index.ts | 9 +-
.../api/remote-access/src/client/mount.ts | 39 +++
.../remote-access/src/client/when-allowed.ts | 85 +++++
packages/api/remote-access/src/index.ts | 13 +-
.../tests/client-access.client.spec.ts | 225 +++++++++++++
.../tests/remote-access.host.spec.ts | 21 +-
.../api/remote-access/tsconfig.client.json | 1 +
.../web-app/tests/document-preview.spec.ts | 7 +-
.../connection/tests/node-half.host.spec.ts | 7 +-
.../shortcuts/tests/config.host.spec.ts | 7 +-
.../ui-directory-picker-native/package.json | 1 +
.../src/client/index.ts | 32 +-
.../tests/client-flow.client.spec.tsx | 18 +-
.../src/client/MessageFeedbackActions.tsx | 15 +-
.../message-feedback-actions.client.spec.tsx | 20 +-
.../client/ui-plugin-manager/package.json | 1 +
.../ui-plugin-manager/src/client/index.ts | 144 ++++-----
.../tests/browser-plugin.client.spec.tsx | 20 +-
packages/client/ui-schedule/package.json | 1 +
.../client/ui-schedule/src/client/index.ts | 305 +++++++++---------
.../tests/browser-plugin.client.spec.ts | 25 +-
packages/client/ui-session/README.i18n.yaml | 4 +-
packages/client/ui-session/README.md | 2 +-
packages/client/ui-session/README.zh.md | 2 +-
.../client/ui-session/src/client/index.ts | 20 +-
.../tests/ui-session.client.spec.ts | 8 +
.../client/ui-settings-account/package.json | 1 +
.../ui-settings-account/src/client/index.ts | 74 +++--
.../tests/apply.client.spec.ts | 10 +
.../tests/config.client.spec.ts | 9 +-
.../tests/initialization.client.spec.ts | 8 +
.../client/ui-settings-general/package.json | 1 +
.../ui-settings-general/src/client/index.ts | 84 ++---
.../tests/apply.client.spec.ts | 2 +-
.../tests/shell.client.spec.ts | 8 +-
.../tests/apply.client.spec.ts | 9 +-
.../tests/config.host.spec.ts | 9 +-
.../client/ui-theme/tests/config.host.spec.ts | 7 +-
packages/client/ui-workspace/README.i18n.yaml | 4 +-
packages/client/ui-workspace/README.md | 2 +-
packages/client/ui-workspace/README.zh.md | 2 +-
.../ui-workspace/src/client/navigation.ts | 20 ++
.../ui-workspace/tests/apply.client.spec.ts | 10 +-
.../tests/bootstrap-route.host.spec.ts | 5 +-
.../inspector/tests/plugin.host.spec.ts | 7 +-
.../webworker-runtime/src/worker-host.ts | 11 +-
.../extensions/tool-cordis/src/api-catalog.ts | 22 +-
.../host/frontend-static/README.i18n.yaml | 4 +-
packages/host/frontend-static/README.md | 2 +-
packages/host/frontend-static/README.zh.md | 2 +-
packages/host/frontend-static/src/index.ts | 3 +-
packages/host/webserver/README.i18n.yaml | 12 +-
packages/host/webserver/README.md | 6 +-
packages/host/webserver/README.zh.md | 6 +-
packages/host/webserver/package.json | 1 +
packages/host/webserver/src/index.ts | 38 ++-
.../host/webserver/tests/webserver.spec.ts | 16 +-
packages/host/webserver/tsconfig.json | 3 +
.../client-runtime/README.i18n.yaml | 12 +-
.../test-support/client-runtime/README.md | 6 +-
.../test-support/client-runtime/README.zh.md | 6 +-
.../test-support/client-runtime/src/access.ts | 82 +++--
.../src/assembly/test-client.ts | 10 +-
.../test-support/client-runtime/src/index.ts | 2 +-
.../tests/access.client.spec.ts | 82 +++++
pnpm-lock.yaml | 18 ++
87 files changed, 1497 insertions(+), 521 deletions(-)
create mode 100644 .agents/notes/implemented/architecture/2026-10-21-when-access.i18n.yaml
create mode 100644 .agents/notes/implemented/architecture/2026-10-21-when-access.md
create mode 100644 .agents/notes/implemented/architecture/2026-10-21-when-access.zh.md
create mode 100644 docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.i18n.yaml
create mode 100644 docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.md
create mode 100644 docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.zh.md
create mode 100644 packages/api/remote-access/src/client/when-allowed.ts
create mode 100644 packages/test-support/client-runtime/tests/access.client.spec.ts
diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml
index fbf95020c0..9378bc4f0b 100644
--- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml
@@ -54,8 +54,8 @@
en: 7865e159528e2d30
zh: 1399d54632f4d926
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/access-snapshot-stream:
- en: 41d7ac1faf2448d7
- zh: 6d523895a9abae68
+ en: a928a63e8c36177c
+ zh: c943c1fdc8c4bb45
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/client-face:
en: 7be28cd7e19e5377
zh: 30b5aee957271d6c
@@ -81,8 +81,8 @@
en: 89452c3c3f042e75
zh: 9bcad00a60c65303
/agent-note-peer-principals-and-method-level-remote-access-control/consequences/verification:
- en: 048067eb02e6bf8a
- zh: 12508b59f6b206b8
+ en: 2279a8c153cc5b89
+ zh: 63c0b6462722f0a3
/agent-note-peer-principals-and-method-level-remote-access-control/consequences/costs-and-limits:
en: 1e254cec680b1a37
zh: 06a31e057ba965aa
diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md
index 324606f3ab..c9894ae409 100644
--- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md
+++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md
@@ -446,7 +446,7 @@ export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__'
| Behavior | Detail |
|---|---|
-| First snapshot | `snapshotFor(principal)` computes a page's opening baseline. remote-access adds the global `REMOTE_ACCESS_SNAPSHOT_KEY`, set to the owner baseline `{ type: 'baseline', owner: true }`, to every index the webServer renders, through `webserver/index-inject`. A business package that renders a non-owner page (such as liveshare on the owner Host) places an assignment of that global to `snapshotFor(principal)` after every index injection row; the later assignment wins, and the Client reads the global when it mounts |
+| First snapshot | `snapshotFor(principal)` computes a page's opening baseline. The webServer renders each index for the Peer the page speaks for, and `webserver/index-inject` passes that Peer to listeners; remote-access adds the global `REMOTE_ACCESS_SNAPSHOT_KEY`, set to `snapshotFor(peer.principal)`, to every index, so the owner's page receives the owner baseline `{ type: 'baseline', owner: true }` and a page rendered for a non-owner Peer (such as a liveshare viewer page on the owner Host) receives that principal's grants. The Client reads the global when it mounts |
| Subscription | Each Peer subscribes to one access snapshot stream, whose baseline replaces the injected one |
| Computation | All bindings of the policy for the principal's kind are merged by equal resource; each resource's `actions` are the endpoint actions of those bindings' roles that the same decision as a call allows (one resource, without args, conditions called without args); only a resource with at least one allowed endpoint action gets a grant. Decisions and snapshots share one source: for the same principal, action, and resource (without arguments), the snapshot answer equals `decide()` |
| Endpoint actions only | `event:` actions do not enter the snapshot; the frontend decides entry visibility from endpoint actions only |
@@ -638,7 +638,7 @@ The review rule's home is the "`@Remote` marks capability methods" section of [R
| Resource resolution | A default field (top-level or first object argument) yields its resource; a lookup parameter that declares that kind as its `resource` yields its wire identity; a Context method gets its Session resource from the Agent Context identity in args without activating the Agent, and may declare other resources through `resources`; a `resources` reader overrides its kind, an array yields several resources, and `undefined` none; with nothing the call is `[global]`; a throw, an empty array, or a value that is not a non-empty string (including a Context identity) is invalid, refused by the Gateway with `resource-invalid` without asking `decide()`; a Context that declares no `resource` resolves by the ordinary rules; resources and `args` read the validated arguments, so an undeclared nested field changes no resource and no condition sees it, and a JSON argument failing validation returns `gateway/input-invalid` before the access check | Gateway host spec (gateway-access) |
| Resource registry | `typert.resources` registers one default field per kind, rejects a second registration and `global`, and withdraws the registration with its fiber; a configured lookup resolver keeps the provider's `resource`; a Host Context adapter keeps its `resource` | typert-registry spec |
| Forwarded events | A non-owner `$events` stream receives an event, whole, only when `event:` is granted on its resources; an event that declares no resources is decided on `global`; an event whose reader returns `undefined` does not reach non-owners, and `decide()` is not asked; the owner receives all | Gateway host spec (gateway-access) |
-| First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the owner baseline into every rendered index; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs |
+| First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the snapshot of the Peer each index speaks for; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs |
| Type inference | The parameter types of `resources` readers are inferred from the method; lookup positions have wire types; a missing property, more parameters than the method has, and a `global` key fail compilation; a binding whose resource kind differs from its role's fails compilation | typert-protocol spec, remote-access spec (type tests) |
| List filtering | `session/list`, `session/search`, `session/control`, and `workspace/follow` return non-owners only items for which `allows()` is true; `workspace/follow` sends a `replace` increment when a delivered Workspace loses visibility, and the Workspace reappears once access returns; owner results are unchanged | session-controller / workspace-controller host spec |
| Access snapshots | The allowed set in a snapshot equals the Host decision for the same principal, action, and resource without arguments; it contains endpoint actions only; a resource with no allowed action gets no grant, and one whose allowed set becomes empty is removed; `invalidate()` and policy registration or removal push increments; a wait that a change ends leaves no abort listener behind; unloading the service ends every open snapshot stream; the owner receives only `{ owner: true }` | remote-access host spec |
diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md
index afbd49de7f..ed124e8159 100644
--- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md
+++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md
@@ -446,7 +446,7 @@ export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__'
| 行为 | 说明 |
|---|---|
-| 首份快照 | `snapshotFor(principal)` 计算页面打开时的 baseline。remote-access 经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入全局变量 `REMOTE_ACCESS_SNAPSHOT_KEY`,值为 owner baseline `{ type: 'baseline', owner: true }`。渲染非 owner 页面的业务包(例如 owner Host 上的 liveshare)在所有 index 注入行之后放置一条把该全局变量赋值为 `snapshotFor(principal)` 的语句;后写的赋值生效,Client 在挂载时读取该全局变量 |
+| 首份快照 | `snapshotFor(principal)` 计算页面打开时的 baseline。webServer 按页面所代表的 Peer 渲染 index,`webserver/index-inject` 把该 Peer 传给监听方;remote-access 为每个 index 加入全局变量 `REMOTE_ACCESS_SNAPSHOT_KEY`,值为 `snapshotFor(peer.principal)`,owner 页面因此得到 owner baseline `{ type: 'baseline', owner: true }`,为非 owner Peer 渲染的页面(例如 owner Host 上的 liveshare 读者页)得到该 principal 的 grant。Client 在挂载时读取该全局变量 |
| 订阅 | 每个 Peer 订阅一条访问快照流,其 baseline 取代注入的快照 |
| 计算 | 取该 principal kind 的 policy 的全部 binding,按资源相等归并;每个资源的 `actions` 为这些 binding 的 role 中、经与调用相同的判定(单个资源、不带 args,condition 以无 args 调用)放行的端点 action;只为至少放行一个端点 action 的资源给出 grant。判定与快照同源:同一 principal、action 与资源(不带参数)上,快照答案与 `decide()` 一致 |
| 只含端点 action | `event:` action 不进入快照;前端只据端点 action 决定入口显隐 |
@@ -638,7 +638,7 @@ GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: Remot
| 资源解析 | 默认字段(顶层或首个对象参数)得到对应资源;声明了该种类 `resource` 的 lookup 参数得到其 wire 身份;Context 方法从 args 中的 Agent Context 身份得到其 session 资源且不激活 Agent,另可经 `resources` 声明其他资源;`resources` 读取函数覆盖该种类,数组为多个资源,`undefined` 为无;都没有为 `[global]`;抛错、空数组或非非空字符串(含 Context 身份)为非法,Gateway 以 `resource-invalid` 拒绝且不询问 `decide()`;未声明 `resource` 的 Context 按普通规则解析;资源与 `args` 读取校验后的参数,未声明的嵌套字段不改变资源、condition 也读不到它,JSON 参数校验失败在访问检查前返回 `gateway/input-invalid` | Gateway host spec(gateway-access) |
| 资源注册表 | `typert.resources` 每个种类登记一个默认字段,拒绝重复登记与 `global`,并随 fiber 撤回;配置的 lookup resolver 保留 provider 的 `resource`;Host Context adapter 保留其 `resource` | typert-registry spec |
| 转发事件 | 非 owner 的 `$events` 流只在 `event:` 于其资源上被授权时整条收到事件;未声明资源的事件按 `global` 判定;读取函数返回 `undefined` 的事件不送达非 owner,且不询问 `decide()`;owner 全部收到 | Gateway host spec(gateway-access) |
-| 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为渲染的每个 index 注入 owner baseline;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec |
+| 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为每个 index 注入其所代表 Peer 的快照;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec |
| 类型推断 | `resources` 读取函数的参数类型从方法推断;lookup 位为 wire 类型;访问不存在的属性、参数多于方法参数与 `global` 键编译失败;binding 的资源种类与 role 不符编译失败 | typert-protocol spec、remote-access spec(类型测试) |
| 列表过滤 | `session/list`、`session/search`、`session/control`、`workspace/follow` 对非 owner 只返回 `allows()` 为 true 的项;已下发工作区失去可见性时 `workspace/follow` 发送 `replace` 增量帧,访问恢复后该工作区重新出现;owner 结果不变 | session-controller / workspace-controller host spec |
| 访问快照 | 快照中的允许集合与 Host 对同一 principal、action、资源(不带参数)的判定一致;只含端点 action;没有放行 action 的资源不出 grant,放行集合变空时推送 `remove`;`invalidate()` 与 policy 注册、撤回后推送增量;被变化结束的等待不残留 abort 监听器;卸载服务即结束所有打开的快照流;owner 只收 `{ owner: true }` | remote-access host spec |
diff --git a/.agents/notes/implemented/architecture/2026-10-21-when-access.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-21-when-access.i18n.yaml
new file mode 100644
index 0000000000..6a7ccaae0a
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-10-21-when-access.i18n.yaml
@@ -0,0 +1,28 @@
+# Bilingual-pair consistency record for 2026-10-21-when-access.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 .agents/notes/implemented/architecture/2026-10-21-when-access.md
+/agent-note-whenaccess-registration-that-follows-access:
+ en: 2ef7cfef514c138a
+ zh: 064e3aecbfafcd9c
+/agent-note-whenaccess-registration-that-follows-access/problem:
+ en: c12d401db33f7d77
+ zh: 3bc2c8ad1a441bd7
+/agent-note-whenaccess-registration-that-follows-access/decision:
+ en: a15c7c13f167e5ac
+ zh: 63bcc2ef60c2f344
+/agent-note-whenaccess-registration-that-follows-access/decision/whenaccess:
+ en: c168de20989cf9a4
+ zh: bf3b0361beea083d
+/agent-note-whenaccess-registration-that-follows-access/decision/plugin-migration:
+ en: 0487c2e2f5e71bde
+ zh: 1750e83e7f9fa1cb
+/agent-note-whenaccess-registration-that-follows-access/alternatives-considered:
+ en: 670e07d9da3ea599
+ zh: 625e8e8bff57ff8c
+/agent-note-whenaccess-registration-that-follows-access/consequences:
+ en: 5c7c7c49446fd140
+ zh: f8fa106040fac4bc
+/agent-note-whenaccess-registration-that-follows-access/related:
+ en: 89073ffbfcff0641
+ zh: 736a180d13a08981
diff --git a/.agents/notes/implemented/architecture/2026-10-21-when-access.md b/.agents/notes/implemented/architecture/2026-10-21-when-access.md
new file mode 100644
index 0000000000..66d8705c57
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-10-21-when-access.md
@@ -0,0 +1,88 @@
+# Agent Note: whenAccess registration that follows access
+
+Status: implemented
+
+English | [中文](2026-10-21-when-access.zh.md)
+
+## Problem
+
+Client plugins need to switch their own registrations on and off with the current Peer's access: sidebar entries, main panels, overlays, and data streams. The existing Client access surface covers only rendering:
+
+| Where | Existing capability | Gap |
+|---|---|---|
+| Components | `useRemoteAccess(action)` (global), `useSessionAccess(sessionId, action)`, `useWorkspaceAccess(workspaceId, action)`, and the Session-scope `useAccess(action)` | Readable only during render; a changed answer re-renders automatically |
+| JS (`apply`, services) | `ctx.remoteAccess.source(kind, id, action)` returns a `HostObservable` | A plugin must read `getSnapshot` and subscribe to changes itself, then register and dispose by hand |
+
+#6486 therefore writes `if (!ctx.remoteAccess.owner) return` at the start of `apply` in 8 plugins: the check is fixed on identity, and registrations do not follow an access change.
+
+## Decision
+
+### whenAccess
+
+The JS side has a `whenAccess` for each of the three access Hooks, from the package that publishes that Hook:
+
+| Interface | Provider | Matching Hook |
+|---|---|---|
+| `ctx.remoteAccess.whenAccess(action, callback)` | remote-access | `useRemoteAccess(action)` |
+| `ctx.uiSession.whenAccess(sessionId, action, callback)` | ui-session | `useSessionAccess(sessionId, action)` |
+| `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)` | ui-workspace | `useWorkspaceAccess(workspaceId, action)` |
+
+```ts ignore-check
+import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client'
+
+/** The global-resource member; the Session and Workspace versions take the resource id first. */
+export interface WhenAccess {
+ /**
+ * Keep `callback`'s effects installed while the current Peer may call `action`
+ * on the resource: run it when the answer becomes true, dispose its effects in
+ * reverse order when it becomes false, and run it again on the next true.
+ * @returns disposer for the observation and any installed effects.
+ */
+ whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void
+}
+```
+
+`AccessEffect` is `(() => void) | Iterable<() => void, void, void>`.
+
+| Rule | Content |
+|---|---|
+| Semantics | Same as `ctx.slots.inject`: the callback returns one disposer or an iterable of disposers; an iterable installs as one transaction and disposes in reverse order; the controller belongs to the caller's fiber and disposes when the plugin unloads |
+| Synchronous run | When the answer is already true, the callback runs synchronously; the owner's answer is always true, so registration order matches direct registration |
+| Stream failure | A non-owner's answer turns false and the installed effects dispose |
+| Callback failure | A setup failure at the call throws synchronously; a setup failure on a later grant stops the controller and is rethrown in a microtask |
+| Layering | remote-access knows only `global`; the Session and Workspace versions are built over remote-access's lower-level `whenResourceAccess(kind, id, action, callback)`, as their Hooks are built over `source(kind, id, action)`. Only the package that owns a resource kind calls `source` and `whenResourceAccess`; other plugins use only the access Hooks and `whenAccess` and pass no kind. All four entry points share [`when-allowed.ts`](../../../../packages/api/remote-access/src/client/when-allowed.ts) |
+
+### Plugin migration
+
+| Plugin | Change | Action |
+|---|---|---|
+| ui-schedule | The task catalog source, delete toast overlay, right-sidebar task tab and title, main panel, sidebar entry, turn-tail card, Session-header reminder catalog, and Session row mark and hover list move into `remoteAccess.whenAccess`; the task tab type and the turn definition stay registered outside | `schedule/catalog` |
+| ui-plugin-manager | Host change subscriptions, refresh toast overlay, main panel (including `pluginNavigation`), and sidebar entry move into `remoteAccess.whenAccess`; the controller and `pluginChanges` stay outside | `pluginManager/listBundles` |
+| ui-settings-general | The settings shell `sidebar.settings` and the `settings.open` shortcut move into `remoteAccess.whenAccess` | `settings/describe` |
+| ui-settings-account | The account state stream (including default model initialization), quota notice, and settings launcher move into `remoteAccess.whenAccess` | `account/watch` |
+| ui-directory-picker-native | Both directory flows move into `remoteAccess.whenAccess` | `directoryPicker/pick` |
+| ui-message-feedback | The message feedback buttons stay registered and the component decides whether to render through the Session-scope `useAccess`; `messageFeedback/*` requests carry `sessionId`, which the Gateway resolves to that Session through the `session` resource's default field, so they declare nothing. The `/feedback` decoration has no Client check: the decoration registry is global while `sessionFeedback/record` is decided per Session, so without access the dialog still opens and the Host refuses the submission, which the dialog reports with its failure code | `messageFeedback/put` |
+| ui-deliverables, ui-open-in-app | Unchanged: an owner-only HTTP route that cannot be read falls back as before | — |
+
+## Alternatives considered
+
+**Return after an owner check at the start of `apply`**: fixed on identity; does not follow an access change.
+
+**Trim the plugin list per connection**: a plugin cannot reload at once when access changes.
+
+**Declare access in the plugin package manifest**: defines an access-control concept in the package manifest.
+
+**One generic method with a resource parameter, or all three versions on remote-access**: business code would pass a kind, and remote-access would name the Session and Workspace resource kinds.
+
+## Consequences
+
+| Consequence | Note |
+|---|---|
+| Registrations follow access changes | The 6 plugins no longer return at the start of `apply`; without a grant, a non-owner sees none of the corresponding entries, which appear after a grant and disappear after a revocation; the owner's behavior is unchanged |
+| The callback must hand back disposers | An effect registered through the plugin's `ctx` inside the callback also hangs on the plugin fiber; a registration whose disposer is not yielded does not dispose on access loss, only when the plugin unloads |
+| Readers receive all plugin code | Plugins load as before; the Gateway decides each call, and `whenAccess` and the Hooks control the UI |
+| Test stand-ins | [`dsh-client-test-runtime`](../../../../packages/test-support/client-runtime/README.md) replaces remote-access with fixed answers through `StaticRemoteAccess`, `staticAccessPlugin`, and `TestClientOptions.access`, so plugin specs cover the refused path |
+
+## Related
+
+- [Peer, principal, and remote access](2026-10-12-peer-principal-and-remote-access.md): this Note extends its Client access face.
diff --git a/.agents/notes/implemented/architecture/2026-10-21-when-access.zh.md b/.agents/notes/implemented/architecture/2026-10-21-when-access.zh.md
new file mode 100644
index 0000000000..a4233dcdfe
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-10-21-when-access.zh.md
@@ -0,0 +1,88 @@
+# Agent Note: 按访问状态注册的 whenAccess
+
+Status: implemented
+
+[English](2026-10-21-when-access.md) | 中文
+
+## 问题
+
+Client 插件需要按当前 Peer 的访问状态开关自己的注册:侧栏入口、主面板、浮层、数据流。现有 Client 访问面只覆盖渲染:
+
+| 位置 | 现有能力 | 缺口 |
+|---|---|---|
+| 组件 | `useRemoteAccess(action)`(global)、`useSessionAccess(sessionId, action)`、`useWorkspaceAccess(workspaceId, action)`、会话作用域的 `useAccess(action)` | 只能在渲染时读,答案变化时自动重渲染 |
+| JS(`apply`、服务) | `ctx.remoteAccess.source(kind, id, action)` 返回 `HostObservable` | 插件要自己读 `getSnapshot`、订阅变化,再手动注册与撤销 |
+
+#6486 因此在 8 个插件的 `apply` 开头写 `if (!ctx.remoteAccess.owner) return`:按身份写死,授权变化后注册不跟随。
+
+## 决策
+
+### whenAccess
+
+JS 侧有与三个访问 Hook 一一对应的 `whenAccess`,由各自 Hook 的发布者提供:
+
+| 接口 | 提供者 | 对应 Hook |
+|---|---|---|
+| `ctx.remoteAccess.whenAccess(action, callback)` | remote-access | `useRemoteAccess(action)` |
+| `ctx.uiSession.whenAccess(sessionId, action, callback)` | ui-session | `useSessionAccess(sessionId, action)` |
+| `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)` | ui-workspace | `useWorkspaceAccess(workspaceId, action)` |
+
+```ts ignore-check
+import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client'
+
+/** The global-resource member; the Session and Workspace versions take the resource id first. */
+export interface WhenAccess {
+ /**
+ * Keep `callback`'s effects installed while the current Peer may call `action`
+ * on the resource: run it when the answer becomes true, dispose its effects in
+ * reverse order when it becomes false, and run it again on the next true.
+ * @returns disposer for the observation and any installed effects.
+ */
+ whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void
+}
+```
+
+`AccessEffect` 为 `(() => void) | Iterable<() => void, void, void>`。
+
+| 规则 | 内容 |
+|---|---|
+| 语义 | 与 `ctx.slots.inject` 相同:回调返回一个 disposer 或一组 disposer;一组时按事务安装、逆序撤销;控制器属于调用方 fiber,插件卸载时撤销 |
+| 同步执行 | 调用时答案为 true 则回调同步执行;owner 的答案恒为 true,注册顺序与直接注册相同 |
+| 快照流失败 | 非 owner 的答案变为 false,已安装的效果撤销 |
+| 回调失败 | 调用时安装失败同步抛出;之后重新授权时安装失败,控制器停止并在微任务中抛出 |
+| 分层 | remote-access 只认识 global;会话版与工作区版经 remote-access 的底层方法 `whenResourceAccess(kind, id, action, callback)` 实现,与它们用 `source(kind, id, action)` 构造 Hook 相同。`source` 与 `whenResourceAccess` 只由拥有该资源种类的包调用,其他插件只用访问 Hook 与 `whenAccess`,不传 kind。四个入口共用 [`when-allowed.ts`](../../../../packages/api/remote-access/src/client/when-allowed.ts) |
+
+### 插件迁移
+
+| 插件 | 改法 | action |
+|---|---|---|
+| ui-schedule | 任务列表数据源、删除提示浮层、右栏任务 tab 与标题、主面板、侧栏入口、turn 尾部卡片、会话头部提醒、会话行标记与悬浮列表包进 `remoteAccess.whenAccess`;任务 tab 类型与 turn 定义的注册留在外面 | `schedule/catalog` |
+| ui-plugin-manager | Host 变更订阅、刷新提示浮层、主面板(含 `pluginNavigation`)、侧栏入口包进 `remoteAccess.whenAccess`;controller 与 `pluginChanges` 留在外面 | `pluginManager/listBundles` |
+| ui-settings-general | 设置外壳 `sidebar.settings` 与 `settings.open` 快捷键包进 `remoteAccess.whenAccess` | `settings/describe` |
+| ui-settings-account | 账号状态流(含默认模型初始化)、额度提醒、设置启动器包进 `remoteAccess.whenAccess` | `account/watch` |
+| ui-directory-picker-native | 两个目录选择流程包进 `remoteAccess.whenAccess` | `directoryPicker/pick` |
+| ui-message-feedback | 消息反馈按钮照常注册,组件在会话作用域里用 `useAccess` 判断是否渲染;`messageFeedback/*` 的请求带 `sessionId`,Gateway 按 `session` 资源的默认字段自动解析为该 Session,无需声明。`/feedback` 装饰不做前端判断:装饰注册表是全局的,而 `sessionFeedback/record` 按会话判定;没有权限时对话框照常打开,提交被 Host 拒绝并显示失败码 | `messageFeedback/put` |
+| ui-deliverables、ui-open-in-app | 不动:owner 专用 HTTP 路由读不到时走已有回退 | — |
+
+## 考虑过的替代方案
+
+**插件 `apply` 开头判断 owner 后 return**:按身份写死,授权变化后不跟随。
+
+**按连接裁剪插件列表**:授权变化后插件无法立即重新加载。
+
+**插件包清单声明访问性**:在包清单上定义访问控制概念。
+
+**一个带资源参数的通用方法,或三个版本都挂在 remote-access 上**:业务代码要传 kind,且 remote-access 会出现会话、工作区的资源种类名。
+
+## 后果
+
+| 后果 | 说明 |
+|---|---|
+| 注册跟随授权变化 | 6 个插件不再在 `apply` 开头 return;非 owner 无授权时相应入口不出现,授予后出现,撤销后消失;owner 行为不变 |
+| 回调必须交还 disposer | 回调里经插件 `ctx` 注册的效果也挂在插件 fiber 上;不 yield 其 disposer 的注册在失权时不会撤销,只在插件卸载时撤销 |
+| 读者拿到全部插件代码 | 插件照常加载;访问由 Gateway 逐次判定,界面由 `whenAccess` 与 Hook 控制 |
+| 测试替身 | [`dsh-client-test-runtime`](../../../../packages/test-support/client-runtime/README.zh.md) 的 `StaticRemoteAccess`、`staticAccessPlugin` 与 `TestClientOptions.access` 以固定答案替换 remote-access,供插件测试无权限路径 |
+
+## 相关
+
+- [Peer、principal 与 remote-access](2026-10-12-peer-principal-and-remote-access.zh.md):本 Note 扩展其 Client 访问面。
diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts
index 7ff0cb6473..6ce80e1aa5 100644
--- a/apps/desktop-host/src/index.ts
+++ b/apps/desktop-host/src/index.ts
@@ -104,7 +104,7 @@ async function main(): Promise {
if (process.connected) process.send?.({ type: 'platform-session', session })
})
const url = ctx.connection.authenticatedUrl(`${ctx.webServer.protocol}//127.0.0.1:${String(ctx.webServer.port)}`)
- if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections() }, (error) => { if (error !== null) console.error(error) })
+ if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections(ctx.connection.browserPeer) }, (error) => { if (error !== null) console.error(error) })
}
/** Upper bound of the startup diagnostic carried over IPC; the head holds the message and stack. */
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 313b63aa2b..c2b9457b1c 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -1706,7 +1706,7 @@ export interface Config {
## `@deepseek-ai/dsh-host-webserver`
-- `source`: [`packages/host/webserver/src/index.ts:76`](../packages/host/webserver/src/index.ts)
+- `source`: [`packages/host/webserver/src/index.ts:79`](../packages/host/webserver/src/index.ts)
```ts config-catalog
/** Web server listen, TLS, and response-compression config. */
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index 56f50c6fe4..f141ef600d 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -1708,7 +1708,7 @@ export interface Config {
## `@deepseek-ai/dsh-host-webserver`
-- `source`: [`packages/host/webserver/src/index.ts:76`](../packages/host/webserver/src/index.ts)
+- `source`: [`packages/host/webserver/src/index.ts:79`](../packages/host/webserver/src/index.ts)
```ts config-catalog
/** Web server listen, TLS, and response-compression config. */
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index a405377a97..0e2c56c782 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -82,7 +82,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` |
-| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
+| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:42`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md
index e6010e3d32..1235fb8224 100644
--- a/docs/event-producer-consumer.zh.md
+++ b/docs/event-producer-consumer.zh.md
@@ -84,7 +84,7 @@
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` |
-| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
+| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:42`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md
index 29b5d4e3b7..d68d4a1a36 100644
--- a/docs/subsystems/typert.md
+++ b/docs/subsystems/typert.md
@@ -561,10 +561,9 @@ decide(request: RemoteAccessRequest): RemoteAccessDecision
allows(resource: RemoteAccessResource): boolean
/**
- * The baseline a page opens with before its snapshot stream connects. A
- * business package rendering a non-owner page assigns this value to
- * {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row,
- * replacing the owner baseline the webServer index carries.
+ * The baseline a page opens with before its snapshot stream connects; every
+ * index the webServer renders carries it under
+ * {@link REMOTE_ACCESS_SNAPSHOT_KEY} for the Peer the page speaks for.
* @param principal - the page's caller identity.
* @returns the owner baseline for the owner; otherwise the principal's current grants.
*/
diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md
index ce33a70436..270e5509bd 100644
--- a/docs/subsystems/typert.zh.md
+++ b/docs/subsystems/typert.zh.md
@@ -561,10 +561,9 @@ decide(request: RemoteAccessRequest): RemoteAccessDecision
allows(resource: RemoteAccessResource): boolean
/**
- * The baseline a page opens with before its snapshot stream connects. A
- * business package rendering a non-owner page assigns this value to
- * {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row,
- * replacing the owner baseline the webServer index carries.
+ * The baseline a page opens with before its snapshot stream connects; every
+ * index the webServer renders carries it under
+ * {@link REMOTE_ACCESS_SNAPSHOT_KEY} for the Peer the page speaks for.
* @param principal - the page's caller identity.
* @returns the owner baseline for the owner; otherwise the principal's current grants.
*/
diff --git a/docs/subsystems/web-server.md b/docs/subsystems/web-server.md
index c85f7a9b08..425c0faadc 100644
--- a/docs/subsystems/web-server.md
+++ b/docs/subsystems/web-server.md
@@ -235,20 +235,23 @@ tapIndex(transform: (html: string) => string): () => void
applyIndexTaps(html: string): string
/**
- * Gather the structured injection table: one `webserver/index-inject` emit,
- * every subscriber pushes its current rows. Fresh per call, so subscribers
- * read live state (module graph, theme preference) at emit time.
+ * Gather the structured injection table for one Peer: one
+ * `webserver/index-inject` emit, every subscriber pushes its current rows.
+ * Fresh per call, so subscribers read live state (module graph, theme
+ * preference, the Peer's access) at emit time.
+ * @param peer - the Peer the page speaks for.
* @returns rows in subscriber activation order.
*/
-collectIndexInjections(): IndexInjection[]
+collectIndexInjections(peer: PeerScope): IndexInjection[]
/**
- * Render one index.html body: the structured injection table first, then
- * the raw `tapIndex` transforms over the result.
+ * Render one index.html body for one Peer: the structured injection table
+ * first, then the raw `tapIndex` transforms over the result.
* @param html - the raw index.html body.
+ * @param peer - the Peer the page speaks for.
* @returns the transformed body.
*/
-renderIndex(html: string): string
+renderIndex(html: string, peer: PeerScope): string
```
Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
@@ -285,17 +288,19 @@ Source: [`packages/client/connection/src/index.ts`](../../packages/client/connec
#### `webserver/index-inject` — emit
-Collect the structured index injection table. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time.
+Collect the structured index injection table for the page `peer` speaks for. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time.
```ts cordis-catalog
/**
- * Collect the structured index injection table. Emitted on every index
- * render and every worker boot-payload request; listeners push their
- * current rows, so a row's data is read fresh at emit time.
+ * Collect the structured index injection table for the page `peer` speaks
+ * for. Emitted on every index render and every worker boot-payload
+ * request; listeners push their current rows, so a row's data is read
+ * fresh at emit time.
* @param table - Mutable row table; listeners append in activation order.
+ * @param peer - The Peer the rendered page speaks for.
* @mode emit
*/
-'webserver/index-inject'(table: IndexInjection[]): void
+'webserver/index-inject'(table: IndexInjection[], peer: PeerScope): void
```
Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
diff --git a/docs/subsystems/web-server.zh.md b/docs/subsystems/web-server.zh.md
index d108ad5f67..d1d469d9e0 100644
--- a/docs/subsystems/web-server.zh.md
+++ b/docs/subsystems/web-server.zh.md
@@ -235,20 +235,23 @@ tapIndex(transform: (html: string) => string): () => void
applyIndexTaps(html: string): string
/**
- * Gather the structured injection table: one `webserver/index-inject` emit,
- * every subscriber pushes its current rows. Fresh per call, so subscribers
- * read live state (module graph, theme preference) at emit time.
+ * Gather the structured injection table for one Peer: one
+ * `webserver/index-inject` emit, every subscriber pushes its current rows.
+ * Fresh per call, so subscribers read live state (module graph, theme
+ * preference, the Peer's access) at emit time.
+ * @param peer - the Peer the page speaks for.
* @returns rows in subscriber activation order.
*/
-collectIndexInjections(): IndexInjection[]
+collectIndexInjections(peer: PeerScope): IndexInjection[]
/**
- * Render one index.html body: the structured injection table first, then
- * the raw `tapIndex` transforms over the result.
+ * Render one index.html body for one Peer: the structured injection table
+ * first, then the raw `tapIndex` transforms over the result.
* @param html - the raw index.html body.
+ * @param peer - the Peer the page speaks for.
* @returns the transformed body.
*/
-renderIndex(html: string): string
+renderIndex(html: string, peer: PeerScope): string
```
Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
@@ -285,17 +288,19 @@ Source: [`packages/client/connection/src/index.ts`](../../packages/client/connec
#### `webserver/index-inject` — emit
-Collect the structured index injection table. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time.
+Collect the structured index injection table for the page `peer` speaks for. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time.
```ts cordis-catalog
/**
- * Collect the structured index injection table. Emitted on every index
- * render and every worker boot-payload request; listeners push their
- * current rows, so a row's data is read fresh at emit time.
+ * Collect the structured index injection table for the page `peer` speaks
+ * for. Emitted on every index render and every worker boot-payload
+ * request; listeners push their current rows, so a row's data is read
+ * fresh at emit time.
* @param table - Mutable row table; listeners append in activation order.
+ * @param peer - The Peer the rendered page speaks for.
* @mode emit
*/
-'webserver/index-inject'(table: IndexInjection[]): void
+'webserver/index-inject'(table: IndexInjection[], peer: PeerScope): void
```
Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
diff --git a/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.i18n.yaml
new file mode 100644
index 0000000000..1a41ab4629
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/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.2.1-rc.2/webserver-index-peer/guide.md
+/:
+ en: 4182112137303167
+ zh: 8451ce873cfca164
+/webserver-index-rendering-takes-a-peer:
+ en: 7986c2aaf1511f53
+ zh: a6f78fc166e1ea19
+/webserver-index-rendering-takes-a-peer/change:
+ en: 07a11388305a0124
+ zh: 662003b29c238d30
+/webserver-index-rendering-takes-a-peer/migration:
+ en: 7ef144b29ffb2f39
+ zh: defb62ecc4ba6c2e
diff --git a/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.md
new file mode 100644
index 0000000000..42f8bdc773
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.md
@@ -0,0 +1,17 @@
+---
+kind: upgrade-guide
+description: "WebServer index rendering takes the Peer the page speaks for, and the `webserver/index-inject` event passes that Peer to listeners."
+---
+# WebServer index rendering takes a Peer
+
+English | [中文](guide.zh.md)
+
+## Change
+
+`renderIndex(html)` and `collectIndexInjections()` on `ctx.webServer` from `@deepseek-ai/dsh-host-webserver` took no Peer, and every index carried the owner's access snapshot. They are now `renderIndex(html, peer)` and `collectIndexInjections(peer)`, and the `webserver/index-inject` event passes the same Peer as its second argument, so the remote-access snapshot in the index belongs to that Peer. Code that calls either method breaks at compile time; listeners that read only the table keep working.
+
+## Migration
+
+1. Pass the Peer the page speaks for to every `ctx.webServer.renderIndex(html)` and `ctx.webServer.collectIndexInjections()` call. A page served to the owner's browser passes `ctx.connection.browserPeer`.
+2. In a test that emits `webserver/index-inject` itself, pass a Peer after the table: `ctx.emit('webserver/index-inject', table, peer)`.
+3. Run your TypeScript build and confirm no call to either method reports a missing argument.
diff --git a/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.zh.md
new file mode 100644
index 0000000000..de6d1c58da
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.zh.md
@@ -0,0 +1,17 @@
+---
+kind: upgrade-guide
+description: "WebServer 渲染 index 时需要传入页面所代表的 Peer,`webserver/index-inject` 事件会把该 Peer 传给监听方。"
+---
+# WebServer 渲染 index 需要传入 Peer
+
+[English](guide.md) | 中文
+
+## 变更
+
+`@deepseek-ai/dsh-host-webserver` 中 `ctx.webServer` 的 `renderIndex(html)` 与 `collectIndexInjections()` 不接收 Peer,每个 index 携带的都是 owner 的访问快照。现在它们改为 `renderIndex(html, peer)` 与 `collectIndexInjections(peer)`,`webserver/index-inject` 事件以第二个参数传入同一个 Peer,index 中的 remote-access 快照因此属于该 Peer。调用这两个方法的代码会在编译时报错;只读取表格的监听方不受影响。
+
+## 迁移
+
+1. 每处 `ctx.webServer.renderIndex(html)` 与 `ctx.webServer.collectIndexInjections()` 调用都传入页面所代表的 Peer。提供给 owner 浏览器的页面传入 `ctx.connection.browserPeer`。
+2. 自行 emit `webserver/index-inject` 的测试在表格之后传入一个 Peer:`ctx.emit('webserver/index-inject', table, peer)`。
+3. 运行 TypeScript 构建,确认这两个方法的调用都不再报缺少参数。
diff --git a/packages/api/remote-access/README.i18n.yaml b/packages/api/remote-access/README.i18n.yaml
index df8ad59235..461af0d1f9 100644
--- a/packages/api/remote-access/README.i18n.yaml
+++ b/packages/api/remote-access/README.i18n.yaml
@@ -15,11 +15,11 @@
en: a18ecc232e3c3933
zh: b8e43580403f6d77
/deepseek-ai-dsh-api-remote-access/host-service-remoteaccessservice-ctx-key-remoteaccess:
- en: 9175835ea5c5bff1
- zh: 0e33b266fdac3d13
+ en: 8a4be795c7aa2ef0
+ zh: 02550b975e2e8d61
/deepseek-ai-dsh-api-remote-access/client-face:
- en: 1db01235b6af84fa
- zh: c0ced511cc1841a0
+ en: d4cf8f646fdf2d39
+ zh: 247f643e991aa806
/deepseek-ai-dsh-api-remote-access/model-experience:
en: 8b07250cecacd3aa
zh: d28ab93980bdcdad
diff --git a/packages/api/remote-access/README.md b/packages/api/remote-access/README.md
index 8d9df9e542..ab35a6b598 100644
--- a/packages/api/remote-access/README.md
+++ b/packages/api/remote-access/README.md
@@ -43,7 +43,7 @@ When `bindings()`, a condition, or `explain()` throws, the decision is refused w
A forwarded event is decided as the action `event:` on the resources its forwarding entry's readers name, or on `global` when the entry declares none, and reaches a non-owner whole or not at all; its payload is never trimmed. An event carries every resource it declares, so a reader that returns `undefined` makes the event's resources invalid, and the Gateway delivers it to no non-owner without asking `decide()`. An event names at most one resource, so collection changes travel on follow streams (`session/follow`, `session/control`, `workspace/follow`), whose methods decide each item inline through `allows()`.
-The service adds a `global` row named `REMOTE_ACCESS_SNAPSHOT_KEY` (`__DSH_REMOTE_ACCESS__`) holding the owner baseline `{ type: 'baseline', owner: true }` to every index the webServer renders through `webserver/index-inject`. A business package that renders a non-owner page, such as a liveshare viewer page rendered on the owner Host, places an assignment of that global to `ctx.remoteAccess.snapshotFor(principal)` after every index injection row, for example a `global` row appended to the end of `ctx.webServer.collectIndexInjections()`. The later assignment replaces the owner baseline, and the Client entry reads the global when it mounts, so the page opens with the viewer's grants instead of the owner's.
+The service adds a `global` row named `REMOTE_ACCESS_SNAPSHOT_KEY` (`__DSH_REMOTE_ACCESS__`) holding `snapshotFor(peer.principal)` to every index the webServer renders through `webserver/index-inject`, for the Peer that render speaks for: the owner's browser page opens with `{ type: 'baseline', owner: true }`, and a page rendered for a non-owner Peer, such as a liveshare viewer page rendered on the owner Host, opens with that viewer's grants. The Client entry reads the global when it mounts.
| `details.reason` of `remote-access/denied` | Cause |
|---|---|
@@ -60,9 +60,10 @@ Snapshot grants list, per resource the principal's bindings name, the sorted end
The Client entry reads the baseline the index injected under `REMOTE_ACCESS_SNAPSHOT_KEY` synchronously, mounts the generated `remoteAccess` contribution itself through `mountAccess(ctx, contribution, initial)`, follows the snapshot stream, and provides:
- the Client service `ctx.remoteAccess.source(kind, id, action)`, an identity-stable boolean source per `(kind, id, action)` for one resource of a declared kind;
+- `ctx.remoteAccess.whenAccess(action, callback)`, which keeps the callback's effects installed while the `global` resource allows `action`: the callback runs when the answer becomes true, its effects dispose in reverse order when it becomes false, and it runs again on the next true. The callback returns one disposer or an iterable of disposers, as with `ctx.slots.inject`; it runs synchronously when the answer is already true, as it always is for the owner; the controller belongs to the caller's fiber. A setup failure while allowed throws at once; a later one retires the controller and is rethrown in a microtask. `whenResourceAccess(kind, id, action, callback)` is the same for one resource of a declared kind;
- the standard Hook `useRemoteAccess(action)`, which answers on the `global` resource.
-Domain UI packages publish their resource Hooks over `source()`: [ui-session](../../client/ui-session/README.md) provides `useSessionAccess(sessionId, action)` and the Session-scope `useAccess(action)` on Session resources, a different name from the `global` `useRemoteAccess`, and [ui-workspace](../../client/ui-workspace/README.md) provides `useWorkspaceAccess(workspaceId, action)` on Workspace resources. A Hook's `action` is a `RemoteEndpoint`, an endpoint some Client Remote namespace declares. A non-owner answer is whether that resource's grant lists the action and never falls back to the `global` answer, so a resource without a grant, or whose grant lists no action, answers false. Every answer is true for the owner; a non-owner page without an injected baseline, and a non-owner page whose stream fails terminally, answers false; the terminal failure is reported with its error, for a non-owner with the advice to reload the page and for the owner with the note that its answers are unaffected; a reconnect keeps the last snapshot. A malformed injected baseline fails the mount. Hooks never issue requests, and the Host still checks every call. [`dsh-client-test-runtime`](../../test-support/client-runtime/README.md) replaces this row with allow-all access in Client specs.
+Domain UI packages publish their resource Hooks over `source()` and their `whenAccess` over `whenResourceAccess()`: [ui-session](../../client/ui-session/README.md) provides `useSessionAccess(sessionId, action)`, the Session-scope `useAccess(action)`, and `ctx.uiSession.whenAccess(sessionId, action, callback)` on Session resources, a different name from the `global` `useRemoteAccess`, and [ui-workspace](../../client/ui-workspace/README.md) provides `useWorkspaceAccess(workspaceId, action)` and `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)` on Workspace resources. Only the package that owns a resource kind calls `source()` and `whenResourceAccess()`; other plugins read access through the access Hooks and the `whenAccess` methods. A Hook's `action` is a `RemoteEndpoint`, an endpoint some Client Remote namespace declares. A non-owner answer is whether that resource's grant lists the action and never falls back to the `global` answer, so a resource without a grant, or whose grant lists no action, answers false. Every answer is true for the owner; a non-owner page without an injected baseline, and a non-owner page whose stream fails terminally, answers false; the terminal failure is reported with its error, for a non-owner with the advice to reload the page and for the owner with the note that its answers are unaffected; a reconnect keeps the last snapshot. A malformed injected baseline fails the mount. Hooks never issue requests, and the Host still checks every call. [`dsh-client-test-runtime`](../../test-support/client-runtime/README.md) replaces this row with fixed answers, allow-all by default, in Client specs.
## Model Experience
diff --git a/packages/api/remote-access/README.zh.md b/packages/api/remote-access/README.zh.md
index 8d593f7132..514f885b33 100644
--- a/packages/api/remote-access/README.zh.md
+++ b/packages/api/remote-access/README.zh.md
@@ -43,7 +43,7 @@ Gateway 依次从 `@Remote` 与 `@RemoteScope` 的 `resources` 读取函数、lo
转发事件按其转发条目的读取函数指明的资源(条目未声明时为 `global`)判定 action `event:`,对非 owner 整条送达或整条不送,从不裁剪 payload。事件携带其声明的每个资源,因此读取函数返回 `undefined` 时该事件的资源非法,Gateway 不询问 `decide()`,不送达任何非 owner。一个事件至多指明一个资源,因此集合类变化走 follow 类流(`session/follow`、`session/control`、`workspace/follow`),由这些方法内联经 `allows()` 逐项判定。
-本服务经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入名为 `REMOTE_ACCESS_SNAPSHOT_KEY`(`__DSH_REMOTE_ACCESS__`)的 `global` 行,值为 owner baseline `{ type: 'baseline', owner: true }`。渲染非 owner 页面的业务包(例如在 owner Host 上渲染 liveshare 读者页)在所有 index 注入行之后放置一条把该全局赋值为 `ctx.remoteAccess.snapshotFor(principal)` 的语句,例如追加到 `ctx.webServer.collectIndexInjections()` 末尾的一条 `global` 行。后写的赋值覆盖 owner baseline,Client 入口在挂载时读取该全局,因此页面以读者的 grant 而非 owner 的快照打开。
+本服务经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入名为 `REMOTE_ACCESS_SNAPSHOT_KEY`(`__DSH_REMOTE_ACCESS__`)的 `global` 行,值为该次渲染所代表 Peer 的 `snapshotFor(peer.principal)`:owner 的浏览器页面以 `{ type: 'baseline', owner: true }` 打开,为非 owner Peer 渲染的页面(例如在 owner Host 上渲染的 liveshare 读者页)以该读者的 grant 打开。Client 入口在挂载时读取该全局。
| `remote-access/denied` 的 `details.reason` | 原因 |
|---|---|
@@ -60,9 +60,10 @@ Gateway 依次从 `@Remote` 与 `@RemoteScope` 的 `resources` 读取函数、lo
Client 入口同步读取 index 注入在 `REMOTE_ACCESS_SNAPSHOT_KEY` 下的 baseline,经 `mountAccess(ctx, contribution, initial)` 自行挂载生成的 `remoteAccess` contribution,订阅快照流,并提供:
- Client 服务 `ctx.remoteAccess.source(kind, id, action)`,为已声明种类的某个资源上的每个 `(kind, id, action)` 提供一个身份稳定的布尔 source;
+- `ctx.remoteAccess.whenAccess(action, callback)`,在 `global` 资源允许 `action` 期间保持回调的效果:答案变为 true 时执行回调,变为 false 时逆序撤销其效果,下一次变为 true 时再次执行。回调返回一个 disposer 或一组 disposer,与 `ctx.slots.inject` 相同;答案已为 true 时(owner 恒为 true)回调同步执行;控制器属于调用方 fiber。允许期间的安装失败立即抛出;之后的安装失败使控制器停止,并在微任务中重新抛出。`whenResourceAccess(kind, id, action, callback)` 对已声明种类的某个资源做同样的事;
- 标准 Hook `useRemoteAccess(action)`,回答 `global` 资源上的访问。
-领域 UI 包在 `source()` 之上发布各自资源的 Hook:[ui-session](../../client/ui-session/README.zh.md) 提供 Session 资源上的 `useSessionAccess(sessionId, action)` 与会话作用域的 `useAccess(action)`(与 `global` 资源上的 `useRemoteAccess` 不同名),[ui-workspace](../../client/ui-workspace/README.zh.md) 提供 Workspace 资源上的 `useWorkspaceAccess(workspaceId, action)`。Hook 的 `action` 类型为 `RemoteEndpoint`,即某个 Client Remote namespace 声明的端点。非 owner 的答案是该资源的 grant 是否列出该 action,不回退 `global` 答案,因此没有 grant 或 grant 未列出任何 action 的资源回答 false。owner 的所有答案为 true;没有注入 baseline 的非 owner 页面、快照流终止失败的非 owner 页面,所有答案为 false;终止失败会连同错误一并报告,非 owner 附“刷新页面重连”的提示,owner 附“答案不受影响”的说明;重连期间保留上次快照。注入的 baseline 格式错误时挂载失败。Hook 从不发请求,Host 仍检查每次调用。[`dsh-client-test-runtime`](../../test-support/client-runtime/README.zh.md) 在 Client spec 中以全允许的访问替换本行。
+领域 UI 包在 `source()` 之上发布各自资源的 Hook,在 `whenResourceAccess()` 之上发布各自的 `whenAccess`:[ui-session](../../client/ui-session/README.zh.md) 提供 Session 资源上的 `useSessionAccess(sessionId, action)`、会话作用域的 `useAccess(action)` 与 `ctx.uiSession.whenAccess(sessionId, action, callback)`(与 `global` 资源上的 `useRemoteAccess` 不同名),[ui-workspace](../../client/ui-workspace/README.zh.md) 提供 Workspace 资源上的 `useWorkspaceAccess(workspaceId, action)` 与 `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)`。只有拥有某个资源种类的包才调用 `source()` 与 `whenResourceAccess()`;其他插件经访问 Hook 与各 `whenAccess` 读取访问。Hook 的 `action` 类型为 `RemoteEndpoint`,即某个 Client Remote namespace 声明的端点。非 owner 的答案是该资源的 grant 是否列出该 action,不回退 `global` 答案,因此没有 grant 或 grant 未列出任何 action 的资源回答 false。owner 的所有答案为 true;没有注入 baseline 的非 owner 页面、快照流终止失败的非 owner 页面,所有答案为 false;终止失败会连同错误一并报告,非 owner 附“刷新页面重连”的提示,owner 附“答案不受影响”的说明;重连期间保留上次快照。注入的 baseline 格式错误时挂载失败。Hook 从不发请求,Host 仍检查每次调用。[`dsh-client-test-runtime`](../../test-support/client-runtime/README.zh.md) 在 Client spec 中以固定答案(默认全允许)的访问替换本行。
## 模型体验
diff --git a/packages/api/remote-access/src/client/index.ts b/packages/api/remote-access/src/client/index.ts
index 396f56a16a..f3e5123b5b 100644
--- a/packages/api/remote-access/src/client/index.ts
+++ b/packages/api/remote-access/src/client/index.ts
@@ -2,8 +2,11 @@
* Client face: starts from the access snapshot the index injected, follows
* the calling Peer's snapshot stream, provides the `remoteAccess` Client
* service whose `source(kind, id, action)` answers for any declared-kind
- * resource, and publishes `useRemoteAccess(action)` on the global resource.
- * Domain UI adapters publish their own resource Hooks over `source()`. Every
+ * resource and whose `whenAccess(action, callback)` keeps effects installed
+ * while the global resource allows an action, and publishes
+ * `useRemoteAccess(action)` on the global resource. Domain UI adapters
+ * publish their own resource Hooks over `source()` and their own
+ * `whenAccess` over `whenResourceAccess()`. Every
* answer is a boolean: true for the owner, false without a snapshot or after
* a non-owner stream fails; the Host enforces every call regardless.
* @module @deepseek-ai/dsh-api-remote-access/client
@@ -18,7 +21,7 @@ import { parseInjectedBaseline } from './state.ts'
export type {
RemoteAccessAction, RemoteAccessEndpoint, RemoteAccessFrame, RemoteAccessGrant, RemoteAccessResource, TypertResourceKind,
} from '../types.ts'
-export type { ClientRemoteAccess, RemoteEndpoint } from './mount.ts'
+export type { AccessEffect, ClientRemoteAccess, RemoteEndpoint } from './mount.ts'
/** Required Client services: the Remote mount face and the Slot registry. */
export const inject = ['remote', 'slots']
diff --git a/packages/api/remote-access/src/client/mount.ts b/packages/api/remote-access/src/client/mount.ts
index 257aa396c2..68b868a8a7 100644
--- a/packages/api/remote-access/src/client/mount.ts
+++ b/packages/api/remote-access/src/client/mount.ts
@@ -19,6 +19,9 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type { TypertRemoteContribution, TypertRemoteMap, TypertResourceKind } from '@deepseek-ai/dsh-typert-protocol'
import type { RemoteAccessFrame } from '../types.ts'
import { ClientAccessState, type Baseline } from './state.ts'
+import { whenAllowed, type AccessEffect } from './when-allowed.ts'
+
+export type { AccessEffect } from './when-allowed.ts'
/** Every endpoint the Client Remote namespaces declare. */
export type RemoteEndpoint = Extract
@@ -29,12 +32,38 @@ export interface ClientRemoteAccess {
* Whether the current Peer may call `action` on one resource.
* True for the owner; false without a baseline and after a non-owner
* stream fails; otherwise whether the resource's grant lists the action.
+ * Only the package that owns a resource kind calls it, to build that kind's
+ * access Hook; other plugins read access through the access Hooks or
+ * `whenAccess`.
* @param kind - resource kind declared in `TypertResourceMap`.
* @param id - resource identity.
* @param action - endpoint action the caller would invoke.
* @returns identity-stable boolean source for this `(kind, id, action)`.
*/
source(kind: TypertResourceKind, id: string, action: string): HostObservable
+ /**
+ * Keep `callback`'s effects installed while the current Peer may call
+ * `action` on the global resource: run it when the answer becomes true,
+ * dispose its effects in reverse order when it becomes false, and run it
+ * again on the next true. The callback runs synchronously when the answer
+ * is already true, as it always is for the owner. Effect forms and fiber
+ * ownership match `ctx.slots.inject`.
+ * @param action - endpoint action the guarded effects depend on.
+ * @param callback - creates one disposer or an iterable of disposers.
+ * @returns idempotent disposer for the observation and any installed effects.
+ */
+ whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void
+ /**
+ * {@link whenAccess} for one resource of a declared kind. Only the package
+ * that owns a resource kind calls it, to build that kind's `whenAccess`, as
+ * it builds the kind's access Hook over {@link source}.
+ * @param kind - resource kind declared in `TypertResourceMap`.
+ * @param id - resource identity.
+ * @param action - endpoint action the guarded effects depend on.
+ * @param callback - creates one disposer or an iterable of disposers.
+ * @returns idempotent disposer for the observation and any installed effects.
+ */
+ whenResourceAccess(kind: TypertResourceKind, id: string, action: RemoteEndpoint, callback: () => AccessEffect): () => void
}
declare module '@deepseek-ai/cordis' {
@@ -66,6 +95,16 @@ class ClientRemoteAccessService extends Service implements ClientRemoteAccess {
source(kind: TypertResourceKind, id: string, action: string): HostObservable {
return this.state.resource(kind, id, action)
}
+
+ // Prototype methods: the Cordis service proxy binds `this.ctx` to the caller's Context.
+ whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void {
+ return whenAllowed(this.ctx, this.state.global(action), callback, `remoteAccess.whenAccess(${JSON.stringify(action)})`)
+ }
+
+ whenResourceAccess(kind: TypertResourceKind, id: string, action: RemoteEndpoint, callback: () => AccessEffect): () => void {
+ return whenAllowed(this.ctx, this.state.resource(kind, id, action), callback,
+ `remoteAccess.whenResourceAccess(${JSON.stringify(kind)}, ${JSON.stringify(id)}, ${JSON.stringify(action)})`)
+ }
}
/**
diff --git a/packages/api/remote-access/src/client/when-allowed.ts b/packages/api/remote-access/src/client/when-allowed.ts
new file mode 100644
index 0000000000..e1c2ba890c
--- /dev/null
+++ b/packages/api/remote-access/src/client/when-allowed.ts
@@ -0,0 +1,85 @@
+/**
+ * The effect controller behind `whenAccess`: install a callback's effects
+ * while one access answer is true and dispose them while it is false.
+ * @module @deepseek-ai/dsh-api-remote-access/src/client/when-allowed
+ */
+
+import type { Context } from '@deepseek-ai/cordis'
+import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
+
+/** One synchronous effect installed while access holds: a disposer, or disposers installed as one transaction. */
+export type AccessEffect = (() => void) | Iterable<() => void, void, void>
+
+/**
+ * Install `callback`'s effects while `answer` is true. The callback runs
+ * synchronously when the answer is already true; a later true runs it again
+ * after a false disposed the previous installation in reverse order. The
+ * controller belongs to `ctx`'s fiber.
+ * @param ctx - the caller's Context; it owns the controller and every installation.
+ * @param answer - the access answer to follow.
+ * @param callback - creates one disposer or an iterable of disposers.
+ * @param label - effect label for diagnostics.
+ * @returns idempotent disposer for the controller and any installed effects.
+ * @throws callback setup failures synchronously when the answer is already true.
+ */
+export function whenAllowed(
+ ctx: Context,
+ answer: HostObservable,
+ callback: () => AccessEffect,
+ label: string,
+): () => void {
+ const disposeController = ctx.effect(() => {
+ let installed: (() => void) | undefined
+ let stopped = false
+
+ const uninstall = (): void => {
+ const dispose = installed
+ installed = undefined
+ dispose?.()
+ }
+
+ const reconcile = (): void => {
+ if (stopped) return
+ const allowed = answer.getSnapshot()
+ if (allowed === (installed !== undefined)) return
+ if (!allowed) {
+ uninstall()
+ return
+ }
+ // Each installation is a nested Cordis effect, so an iterable callback
+ // installs transactionally and disposes in reverse order.
+ const disposeEffect = ctx.effect(callback, `${label}: allowed`)
+ installed = () => { void disposeEffect() }
+ }
+
+ const unsubscribe = answer.subscribe(() => { changed() })
+
+ const stop = (): void => {
+ if (stopped) return
+ // A failed installation retires the controller: a later grant never retries it.
+ stopped = true
+ unsubscribe()
+ uninstall()
+ }
+
+ const changed = (): void => {
+ try {
+ reconcile()
+ } catch (error) {
+ stop()
+ if ((error as { code?: unknown } | null)?.code === 'INACTIVE_EFFECT') return
+ const failure = error instanceof Error ? error : new Error(String(error))
+ queueMicrotask(() => { throw failure })
+ }
+ }
+
+ try {
+ reconcile()
+ } catch (error) {
+ stop()
+ throw error
+ }
+ return stop
+ }, label)
+ return () => { void disposeController() }
+}
diff --git a/packages/api/remote-access/src/index.ts b/packages/api/remote-access/src/index.ts
index 5b3cf41e0f..98ee668ca4 100644
--- a/packages/api/remote-access/src/index.ts
+++ b/packages/api/remote-access/src/index.ts
@@ -134,10 +134,10 @@ export class RemoteAccessService extends TypertRemoteService {
constructor(ctx: Context) {
super(ctx, 'remoteAccess')
ctx.effect(() => () => { this.lifetime.abort() }, 'remoteAccess: snapshot generations')
- // Every index the webServer renders belongs to the owner; snapshotFor documents non-owner pages.
+ // Every index opens with the access snapshot of the Peer it speaks for.
ctx.inject(['webServer'], (webCtx) => {
- webCtx.on('webserver/index-inject', (table) => {
- table.push({ kind: 'global', name: REMOTE_ACCESS_SNAPSHOT_KEY, value: OWNER_BASELINE })
+ webCtx.on('webserver/index-inject', (table, peer) => {
+ table.push({ kind: 'global', name: REMOTE_ACCESS_SNAPSHOT_KEY, value: this.snapshotFor(peer.principal) })
})
})
}
@@ -244,10 +244,9 @@ export class RemoteAccessService extends TypertRemoteService {
}
/**
- * The baseline a page opens with before its snapshot stream connects. A
- * business package rendering a non-owner page assigns this value to
- * {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row,
- * replacing the owner baseline the webServer index carries.
+ * The baseline a page opens with before its snapshot stream connects; every
+ * index the webServer renders carries it under
+ * {@link REMOTE_ACCESS_SNAPSHOT_KEY} for the Peer the page speaks for.
* @param principal - the page's caller identity.
* @returns the owner baseline for the owner; otherwise the principal's current grants.
*/
diff --git a/packages/api/remote-access/tests/client-access.client.spec.ts b/packages/api/remote-access/tests/client-access.client.spec.ts
index b73944ec58..5a70579b01 100644
--- a/packages/api/remote-access/tests/client-access.client.spec.ts
+++ b/packages/api/remote-access/tests/client-access.client.spec.ts
@@ -5,6 +5,7 @@ import type { TypertRemoteContribution, TypertResource } from '@deepseek-ai/dsh-
import type { RemoteAccessFrame, RemoteAccessGrant } from '../src/types.ts'
import { ClientAccessState, parseInjectedBaseline, type Baseline } from '../src/client/state.ts'
import { mountAccess } from '../src/client/mount.ts'
+import { whenAllowed } from '../src/client/when-allowed.ts'
declare module '@deepseek-ai/dsh-typert-protocol' {
interface TypertResourceMap {
@@ -12,6 +13,10 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
'test-shelf': TypertResource
'test-desk': TypertResource
}
+ interface TypertRemoteMap {
+ /** Test-only endpoint the `whenAccess` specs guard effects on. */
+ 'test/guarded': () => Promise
+ }
}
const GRANTED: Extract = {
@@ -289,3 +294,223 @@ describe('remote-access Client mount', () => {
await access.dispose()
})
})
+
+/** A baseline whose grants allow `test/guarded` on the listed resources. */
+function followOn(...resources: RemoteAccessGrant['resource'][]): Extract {
+ return { type: 'baseline', owner: false, grants: resources.map(resource => ({ resource, actions: ['test/guarded'] })) }
+}
+
+describe('remote-access whenAccess', () => {
+ it('installs the effects while the global resource allows the action, disposes them in reverse order on loss, and reinstalls on the next grant', async () => {
+ const access = await mounted()
+ const log: string[] = []
+ const guarded = access.ctx.plugin({
+ inject: ['remoteAccess'],
+ apply: (ctx: Context) => {
+ ctx.remoteAccess.whenAccess('test/guarded', function* () {
+ log.push('install')
+ yield () => { log.push('dispose first') }
+ yield () => { log.push('dispose second') }
+ })
+ },
+ })
+ await guarded.await()
+ expect(log).toEqual([])
+
+ access.stream().push(followOn({ kind: 'global' }))
+ await vi.waitFor(() => { expect(log).toEqual(['install']) })
+ // A frame that leaves the answer unchanged installs nothing again.
+ access.stream().push({ type: 'upsert', grant: { resource: { kind: 'test-shelf', id: 's1' }, actions: ['session/fork'] } })
+ access.stream().push({ type: 'upsert', grant: { resource: { kind: 'global' }, actions: ['session/list'] } })
+ await vi.waitFor(() => { expect(log).toEqual(['install', 'dispose second', 'dispose first']) })
+ access.stream().push({ type: 'upsert', grant: { resource: { kind: 'global' }, actions: ['test/guarded'] } })
+ await vi.waitFor(() => { expect(log).toHaveLength(4) })
+ expect(log[3]).toBe('install')
+
+ await guarded.dispose()
+ expect(log.slice(4)).toEqual(['dispose second', 'dispose first'])
+ await access.dispose()
+ })
+
+ it('runs the callback at once for the owner and disposes it with the returned disposer', async () => {
+ const access = await mounted({ type: 'baseline', owner: true })
+ const dispose = vi.fn()
+ const callback = vi.fn(() => dispose)
+ const stop = access.ctx.remoteAccess.whenAccess('test/guarded', callback)
+ expect(callback).toHaveBeenCalledOnce()
+ stop()
+ await vi.waitFor(() => { expect(dispose).toHaveBeenCalledOnce() })
+ await access.dispose()
+ })
+
+ it('follows one resource of a declared kind through whenResourceAccess', async () => {
+ const access = await mounted(followOn({ kind: 'test-shelf', id: 's1' }))
+ const dispose = vi.fn()
+ const shelf = vi.fn(() => dispose)
+ const desk = vi.fn(() => () => {})
+ access.ctx.remoteAccess.whenResourceAccess('test-shelf', 's1', 'test/guarded', shelf)
+ access.ctx.remoteAccess.whenResourceAccess('test-desk', 's1', 'test/guarded', desk)
+ expect(shelf).toHaveBeenCalledOnce()
+ expect(desk).not.toHaveBeenCalled()
+ access.stream().push({ type: 'remove', resource: { kind: 'test-shelf', id: 's1' } })
+ await vi.waitFor(() => { expect(dispose).toHaveBeenCalledOnce() })
+ await access.dispose()
+ })
+
+ it('disposes a non-owner\'s effects once its stream fails terminally', async () => {
+ const access = await mounted(followOn({ kind: 'global' }))
+ vi.spyOn(access.ctx.logger, 'error').mockImplementation(() => undefined)
+ const dispose = vi.fn()
+ access.ctx.remoteAccess.whenAccess('test/guarded', () => dispose)
+ access.stream().fail(new Error('carrier closed for good'))
+ await vi.waitFor(() => { expect(dispose).toHaveBeenCalledOnce() })
+ await access.dispose()
+ })
+
+ it('throws a setup failure at once while allowed, and reports a later one without retrying it', async () => {
+ const owner = await mounted({ type: 'baseline', owner: true })
+ expect(() => owner.ctx.remoteAccess.whenAccess('test/guarded', () => { throw new Error('setup failed') }))
+ .toThrow('setup failed')
+ await owner.dispose()
+
+ const access = await mounted()
+ const failures: unknown[] = []
+ const onLoud = (error: unknown): void => { failures.push(error) }
+ process.on('uncaughtException', onLoud)
+ try {
+ const failing = vi.fn((): () => void => { throw new Error('late failure') })
+ const throwsValue = vi.fn(function* (): Generator<() => void> {
+ yield () => {}
+ throw new Error('late value')
+ })
+ const later = vi.fn(() => () => {})
+ access.ctx.remoteAccess.whenAccess('test/guarded', failing)
+ access.ctx.remoteAccess.whenAccess('test/guarded', throwsValue)
+ access.ctx.remoteAccess.whenAccess('test/guarded', later)
+ access.stream().push(followOn({ kind: 'global' }))
+ await vi.waitFor(() => { expect(failures).toHaveLength(2) })
+ expect(failures.map(String)).toEqual(['Error: late failure', 'Error: late value'])
+ expect(later).toHaveBeenCalledOnce()
+ access.stream().push({ type: 'remove', resource: { kind: 'global' } })
+ access.stream().push({ type: 'upsert', grant: { resource: { kind: 'global' }, actions: ['test/guarded'] } })
+ await vi.waitFor(() => { expect(later).toHaveBeenCalledTimes(2) })
+ expect(failing).toHaveBeenCalledOnce()
+ expect(throwsValue).toHaveBeenCalledOnce()
+ } finally {
+ process.off('uncaughtException', onLoud)
+ }
+ await access.dispose()
+ })
+
+ it('skips a controller another installation stopped during the same notification', async () => {
+ const access = await mounted()
+ let stopLater = (): void => {}
+ const first = vi.fn(() => {
+ stopLater()
+ return () => {}
+ })
+ const later = vi.fn(() => () => {})
+ access.ctx.remoteAccess.whenAccess('test/guarded', first)
+ stopLater = access.ctx.remoteAccess.whenAccess('test/guarded', later)
+ access.stream().push(followOn({ kind: 'global' }))
+ await vi.waitFor(() => { expect(first).toHaveBeenCalledOnce() })
+ expect(later).not.toHaveBeenCalled()
+ await access.dispose()
+ })
+
+ it('installs nothing for a grant that arrives while its caller is unloading', async () => {
+ const access = await mounted()
+ const callback = vi.fn(() => () => {})
+ let release = (): void => {}
+ const blocked = new Promise((resolve) => { release = resolve })
+ const pauseUnload = vi.fn(async () => { await blocked })
+ const caller = access.ctx.plugin({
+ inject: ['remoteAccess'],
+ apply: (ctx: Context) => {
+ ctx.remoteAccess.whenAccess('test/guarded', callback)
+ ctx.effect(() => pauseUnload, 'pause caller unload')
+ },
+ })
+ await caller.await()
+ const disposing = caller.dispose()
+ await vi.waitFor(() => { expect(pauseUnload).toHaveBeenCalledOnce() })
+ access.stream().push(followOn({ kind: 'global' }))
+ await vi.waitFor(() => { expect(value(access.global('test/guarded'))).toBe(true) })
+ expect(callback).not.toHaveBeenCalled()
+ release()
+ await disposing
+ await access.dispose()
+ })
+})
+
+/** A hand-driven access answer that notifies synchronously. */
+function drivenAnswer(): { readonly answer: HostObservable; set(allowed: boolean): void } {
+ let allowed = false
+ const listeners = new Set<() => void>()
+ return {
+ answer: {
+ getSnapshot: () => allowed,
+ subscribe: (listener) => {
+ listeners.add(listener)
+ return () => { listeners.delete(listener) }
+ },
+ },
+ set(next) {
+ allowed = next
+ for (const listener of [...listeners]) listener()
+ },
+ }
+}
+
+describe('whenAllowed', () => {
+ it('installs nothing when the answer turns true while its caller is unloading', async () => {
+ const root = new Context()
+ const driven = drivenAnswer()
+ const callback = vi.fn(() => () => {})
+ const caller = root.plugin({
+ apply: (ctx: Context) => {
+ whenAllowed(ctx, driven.answer, callback, 'test')
+ // Disposed before the controller: it turns the answer true mid-unload.
+ ctx.effect(() => () => { driven.set(true) }, 'grant during unload')
+ },
+ })
+ await caller.await()
+ await caller.dispose()
+ expect(callback).not.toHaveBeenCalled()
+ await root.fiber.dispose()
+ })
+
+ it('ignores its disposer after a later failure retired it, and reports a non-Error failure as an Error', async () => {
+ const root = new Context()
+ const driven = drivenAnswer()
+ const failures: unknown[] = []
+ const onLoud = (error: unknown): void => { failures.push(error) }
+ process.on('uncaughtException', onLoud)
+ try {
+ const stop = whenAllowed(root, driven.answer, () => { throw new Error('late failure') }, 'test')
+ driven.set(true)
+ await vi.waitFor(() => { expect(failures).toHaveLength(1) })
+ expect(() => { stop() }).not.toThrow()
+ // A faulty answer that throws a non-Error value is reported as an Error naming it.
+ let reads = 0
+ const listeners: (() => void)[] = []
+ whenAllowed(root, {
+ getSnapshot: () => {
+ reads++
+ if (reads > 1) throw 'unreadable answer'
+ return false
+ },
+ subscribe: (listener) => {
+ listeners.push(listener)
+ return () => {}
+ },
+ }, () => () => {}, 'test')
+ for (const listener of listeners) listener()
+ await vi.waitFor(() => { expect(failures).toHaveLength(2) })
+ expect(String(failures[1])).toBe('Error: unreadable answer')
+ } finally {
+ process.off('uncaughtException', onLoud)
+ }
+ await root.fiber.dispose()
+ })
+})
diff --git a/packages/api/remote-access/tests/remote-access.host.spec.ts b/packages/api/remote-access/tests/remote-access.host.spec.ts
index f29d4d1744..101991e0c4 100644
--- a/packages/api/remote-access/tests/remote-access.host.spec.ts
+++ b/packages/api/remote-access/tests/remote-access.host.spec.ts
@@ -400,18 +400,27 @@ describe('first snapshots', () => {
expect(REMOTE_ACCESS_SNAPSHOT_KEY).toBe('__DSH_REMOTE_ACCESS__')
})
- it('injects the owner baseline into every index the webServer renders', async () => {
- const { ctx } = await setup()
- const render = (): IndexInjection[] => {
+ it('injects the access snapshot of the Peer each index speaks for', async () => {
+ const { ctx, access } = await setup()
+ const policy = new GuestPolicy()
+ policy.hold(GUEST, { role: READER, resource: GLOBAL })
+ access.registerPolicy('test-guest', policy)
+ const render = (principal: Principal): IndexInjection[] => {
const table: IndexInjection[] = []
- ctx.emit('webserver/index-inject', table)
+ ctx.emit('webserver/index-inject', table, new Peer(ctx, { principal }))
return table
}
- expect(render()).toEqual([])
+ expect(render(OWNER)).toEqual([])
ctx.provide('webServer', {} as Context['webServer'])
await vi.waitFor(() => {
- expect(render()).toEqual([{ kind: 'global', name: '__DSH_REMOTE_ACCESS__', value: { type: 'baseline', owner: true } }])
+ expect(render(OWNER)).toEqual([{ kind: 'global', name: '__DSH_REMOTE_ACCESS__', value: { type: 'baseline', owner: true } }])
})
+ expect(render(GUEST)).toEqual([{ kind: 'global', name: '__DSH_REMOTE_ACCESS__', value: {
+ type: 'baseline', owner: false, grants: [{ resource: GLOBAL, actions: ['notes/read', 'notes/rooms', 'notes/write'] }],
+ } }])
+ // A grant change reaches the next render.
+ policy.hold(GUEST)
+ expect(render(GUEST)).toEqual([{ kind: 'global', name: '__DSH_REMOTE_ACCESS__', value: { type: 'baseline', owner: false, grants: [] } }])
})
it('lists per bound resource the endpoint actions decide() allows there without arguments, and no grant where none is allowed', async () => {
diff --git a/packages/api/remote-access/tsconfig.client.json b/packages/api/remote-access/tsconfig.client.json
index 0ddeaeac30..c365c4f780 100644
--- a/packages/api/remote-access/tsconfig.client.json
+++ b/packages/api/remote-access/tsconfig.client.json
@@ -9,6 +9,7 @@
"src/client/index.ts",
"src/client/mount.ts",
"src/client/state.ts",
+ "src/client/when-allowed.ts",
"src/snapshot.ts",
"src/types.ts"
],
diff --git a/packages/bundle/web-app/tests/document-preview.spec.ts b/packages/bundle/web-app/tests/document-preview.spec.ts
index dcf2bcff18..9af5ca6a4f 100644
--- a/packages/bundle/web-app/tests/document-preview.spec.ts
+++ b/packages/bundle/web-app/tests/document-preview.spec.ts
@@ -10,7 +10,7 @@ import { loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
import WorkspaceFiles, { type WorkspaceFileScope } from '@deepseek-ai/dsh-api-workspace-files'
import OfficeToPdf from '@deepseek-ai/dsh-office-to-pdf'
import * as DocumentPreview from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview'
-import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
+import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
@@ -20,6 +20,9 @@ import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import type { Converter, ConverterOptions } from '@deepseek-ai/libreoffice-kit'
import { expect, it, onTestFinished, vi } from 'vitest'
+/** The Peer an index render speaks for; this listener reads no field of it. */
+const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters[1]
+
const kit = vi.hoisted(() => ({ create: vi.fn<(options?: ConverterOptions) => Promise>() }))
vi.mock('@deepseek-ai/libreoffice-kit', () => ({ createConverter: kit.create }))
@@ -92,7 +95,7 @@ it('loads the shipped Office rows with separately patched settings and authorize
const entries = new Map([...ctx.loader.entries()].map(entry => [entry.options.id, entry]))
for (const row of configured) await entries.get(row.id)!.fiber!.await()
const injections: IndexInjection[] = []
- ctx.emit('webserver/index-inject', injections)
+ ctx.emit('webserver/index-inject', injections, PAGE_PEER)
expect(injections).toEqual([
{ kind: 'global', name: '__DSH_DOCUMENT_PREVIEW_CONFIG__', value: clientConfig },
])
diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts
index b3793c9cfd..c3497a95c2 100644
--- a/packages/client/connection/tests/node-half.host.spec.ts
+++ b/packages/client/connection/tests/node-half.host.spec.ts
@@ -22,6 +22,9 @@ import {
import { DEFAULT_MAX_REQUEST_BODY_BYTES } from '../src/http-bridge.ts'
import { provideBrowserCredentials } from './browser-credentials.ts'
+/** The Peer an index render speaks for; this listener reads no field of it. */
+const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters[1]
+
/** Structural webServer fake recording both route registries. */
function fakeHttpServer(
routes: WebRoute[],
@@ -272,7 +275,7 @@ describe('connection node half', () => {
const { ctx, dispose } = await mounted({ recovery: { generationReadyTimeoutMs: 25_000 } })
try {
const rows: IndexInjection[] = []
- ctx.emit('webserver/index-inject', rows)
+ ctx.emit('webserver/index-inject', rows, PAGE_PEER)
expect(rows).toEqual([{
kind: 'global', name: '__DSH_CONNECTION_RECOVERY__', value: {
backoffBaseMs: 500, backoffFactor: 2, backoffMaxMs: 10_000,
@@ -281,7 +284,7 @@ describe('connection node half', () => {
}])
await dispose()
const after: IndexInjection[] = []
- ctx.emit('webserver/index-inject', after)
+ ctx.emit('webserver/index-inject', after, PAGE_PEER)
expect(after).toEqual([])
} finally {
await dispose()
diff --git a/packages/client/shortcuts/tests/config.host.spec.ts b/packages/client/shortcuts/tests/config.host.spec.ts
index 13ad7bc00a..ec507217c5 100644
--- a/packages/client/shortcuts/tests/config.host.spec.ts
+++ b/packages/client/shortcuts/tests/config.host.spec.ts
@@ -1,8 +1,11 @@
import { Context } from '@deepseek-ai/cordis'
-import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
+import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver'
import { expect, it } from 'vitest'
import { Config, apply } from '../src/index.ts'
+/** The Peer an index render speaks for; this listener reads no field of it. */
+const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters[1]
+
it('validates positive sequence timing and publishes it through the product page injection', async () => {
expect(Config({}).stopSequenceMs).toBe(500)
expect(Config({ stopSequenceMs: 2_147_483_646 }).stopSequenceMs).toBe(2_147_483_646)
@@ -11,7 +14,7 @@ it('validates positive sequence timing and publishes it through the product page
try {
apply(ctx, Config({ stopSequenceMs: 800 }))
const table: IndexInjection[] = []
- await ctx.parallel('webserver/index-inject', table)
+ await ctx.parallel('webserver/index-inject', table, PAGE_PEER)
expect(table).toEqual([{ kind: 'global', name: '__DSH_SHORTCUTS_CONFIG__', value: { stopSequenceMs: 800 } }])
} finally { await ctx.fiber.dispose() }
})
diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json
index 6f54248246..ff25c9ae17 100644
--- a/packages/client/ui-directory-picker-native/package.json
+++ b/packages/client/ui-directory-picker-native/package.json
@@ -44,6 +44,7 @@
"@deepseek-ai/cordis": "workspace:~"
},
"devDependencies": {
+ "@deepseek-ai/dsh-api-remote-access": "workspace:*",
"@deepseek-ai/dsh-client-test-runtime": "workspace:*",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
"@deepseek-ai/dsh-client-ui-workspace": "workspace:*",
diff --git a/packages/client/ui-directory-picker-native/src/client/index.ts b/packages/client/ui-directory-picker-native/src/client/index.ts
index efec56fc49..1bdcafecdd 100644
--- a/packages/client/ui-directory-picker-native/src/client/index.ts
+++ b/packages/client/ui-directory-picker-native/src/client/index.ts
@@ -1,5 +1,7 @@
/** Native directory flow using the local desktop bridge or the Host's OS chooser. */
import type { Context as ClientContext } from '@deepseek-ai/cordis'
+// Type-only: pulls the remoteAccess service merge (ctx.remoteAccess).
+import type {} from '@deepseek-ai/dsh-api-remote-access/client'
// Type-only: pulls the SlotMap merge declaring the directory-flow holes.
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
// Type-only: pulls the SlotRegistry service merge (ctx.slots).
@@ -8,13 +10,14 @@ import type { NativeFlowInjected } from './flow.ts'
import { NativeDirectoryFlow } from './flow.ts'
-/** Required services (cordis fiber inject): the slot registry and workspace UI service. */
-export const inject = ['slots', 'uiWorkspace']
+/** Required services (cordis fiber inject): the slot registry, workspace UI service, and access. */
+export const inject = ['slots', 'uiWorkspace', 'remoteAccess']
/**
- * Client plugin body: register the renderless native flow into both
- * directory-flow holes through `slots.inject()` because the ui-workspace
- * entries may activate later or replace their declarations.
+ * Client plugin body: while the current Peer may open the Host's directory
+ * chooser, register the renderless native flow into both directory-flow holes
+ * through `slots.inject()` because the ui-workspace entries may activate later
+ * or replace their declarations.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
@@ -26,13 +29,14 @@ export function apply(ctx: ClientContext): void {
// Both declaration lifetimes must be live before the pair installs; the
// generator makes the two registrations one transactional effect. The
// outer/inner nesting order is arbitrary; neither hole has precedence.
- ctx.slots.inject('conversation.hero.workspace.directoryFlow', () =>
- ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () {
- yield ctx.slots.register({
- name: 'conversation.hero.workspace.directoryFlow', inject: injected,
- }, NativeDirectoryFlow)
- yield ctx.slots.register({
- name: 'sidebar.workspaces.directoryFlow', inject: injected,
- }, NativeDirectoryFlow)
- }))
+ ctx.remoteAccess.whenAccess('directoryPicker/pick', () =>
+ ctx.slots.inject('conversation.hero.workspace.directoryFlow', () =>
+ ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () {
+ yield ctx.slots.register({
+ name: 'conversation.hero.workspace.directoryFlow', inject: injected,
+ }, NativeDirectoryFlow)
+ yield ctx.slots.register({
+ name: 'sidebar.workspaces.directoryFlow', inject: injected,
+ }, NativeDirectoryFlow)
+ })))
}
diff --git a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx
index ed033999aa..fd3b22d315 100644
--- a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx
+++ b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { afterEach } from 'vitest'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
+import { StaticRemoteAccess } from '@deepseek-ai/dsh-client-test-runtime'
import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
import { apply, inject } from '../src/client/index.ts'
import { NativeDirectoryFlow } from '../src/client/flow.ts'
@@ -29,9 +30,10 @@ afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks(); deskto
const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const
-async function bench() {
+async function bench(allows: (action: string) => boolean = () => true) {
const ctx = new Context()
await ctx.plugin(SlotRegistry).await()
+ new StaticRemoteAccess(ctx, allows)
const pickDirectory = vi.fn(async (): Promise => '/tmp/picked')
ctx.provide('uiWorkspace', { pickDirectory } as never)
const slots = ctx.get('slots') as SlotRegistry
@@ -52,7 +54,19 @@ function owner(overrides: Partial = {}): DirectoryFlowO
describe('directory-picker-native client half', () => {
it('declares the services it drives', () => {
- expect(inject).toEqual(['slots', 'uiWorkspace'])
+ expect(inject).toEqual(['slots', 'uiWorkspace', 'remoteAccess'])
+ })
+
+ it('fills no directory flow while the current Peer may not open the Host chooser', async () => {
+ const allows = vi.fn(() => false)
+ const b = await bench(allows)
+ b.declare()
+ const fiber = b.ctx.plugin({ inject: [...inject], apply })
+ await fiber.await()
+ expect(allows).toHaveBeenCalledWith('directoryPicker/pick')
+ for (const hole of HOLES) expect(b.slots.entries(hole)).toEqual([])
+ await fiber.dispose()
+ await b.ctx.fiber.dispose()
})
it('fills both directory-flow holes for declarations before or after apply, and leaves with its fiber', async () => {
diff --git a/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx b/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx
index 6709f79984..c95a28d21b 100644
--- a/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx
+++ b/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx
@@ -17,12 +17,17 @@ import type { MessageFeedbackActionProps } from './slots.ts'
import css from './MessageFeedbackActions.module.css'
/**
- * One message's feedback controls.
- * @param props - the owner's message identity, the injected verbs, and the
- * shared feedback hook.
- * @returns the rating buttons with any failure notice beside them.
+ * One message's feedback controls, rendered only while the current Peer may
+ * record message feedback in this Session.
+ * @param props - the owner's message identity, the injected verbs, the
+ * shared feedback hook, and the Session access Hook.
+ * @returns the rating buttons with any failure notice beside them, or nothing without access.
*/
-export function MessageFeedbackActions({
+export function MessageFeedbackActions(props: MessageFeedbackActionProps) {
+ return props.useAccess('messageFeedback/put') ? : null
+}
+
+function FeedbackRatingButtons({
messageId, ensure, current, retract, openDialog, useFeedback, t,
}: MessageFeedbackActionProps) {
const item = useFeedback(view => view.items.get(messageId))
diff --git a/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx b/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx
index 4121676697..2e80707a58 100644
--- a/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx
+++ b/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx
@@ -45,6 +45,8 @@ function mount(options: {
ensureResult?: MessageFeedbackActionResult
retractResult?: MessageFeedbackActionResult
status?: MessageFeedbackView['status']
+ /** The Session access answer for recording message feedback; allowed when omitted. */
+ allowed?: boolean
} = {}) {
const view: MessageFeedbackView = {
status: options.status ?? 'ready',
@@ -58,12 +60,22 @@ function mount(options: {
const current = vi.fn((_id: MessageId) => options.committed ?? options.current)
const useFeedback = ((select: (v: MessageFeedbackView) => T): T =>
useSyncExternalStore(() => () => {}, () => select(view))) as never
+ const useAccess = vi.fn((_action: string) => options.allowed ?? true)
const props = { messageId: MSG, ensure, current, retract, openDialog, useFeedback, t } as unknown as
Parameters[0]
- return { ...render(), ensure, retract, openDialog }
+ return { ...render(), ensure, retract, openDialog, useAccess }
}
describe('MessageFeedbackActions', () => {
+ it('renders nothing and reads nothing while the current Peer may not record message feedback in this Session', () => {
+ const ui = mount({ allowed: false })
+
+ expect(ui.useAccess).toHaveBeenCalledWith('messageFeedback/put')
+ expect(ui.queryByLabelText(zh['action.like'])).toBeNull()
+ expect(ui.queryByLabelText(zh['action.dislike'])).toBeNull()
+ expect(ui.ensure).not.toHaveBeenCalled()
+ })
+
it('renders both rating buttons unpressed with no recorded feedback', () => {
const ui = mount()
@@ -222,7 +234,7 @@ describe('MessageFeedbackActions', () => {
useFeedback,
t,
} as unknown as Parameters[0]
- const ui = render()
+ const ui = render( true} />)
const errors: unknown[] = []
const onError = (event: ErrorEvent): void => { errors.push(event.error) }
window.addEventListener('error', onError)
@@ -252,7 +264,7 @@ describe('MessageFeedbackActions', () => {
useFeedback,
t,
} as unknown as Parameters[0]
- const ui = render()
+ const ui = render( true} />)
const errors: unknown[] = []
const onError = (event: ErrorEvent): void => { errors.push(event.error) }
window.addEventListener('error', onError)
@@ -286,7 +298,7 @@ describe('MessageFeedbackActions', () => {
useFeedback,
t,
} as unknown as Parameters[0]
- const ui = render()
+ const ui = render( true} />)
const errors: unknown[] = []
const onError = (event: ErrorEvent): void => { errors.push(event.error) }
window.addEventListener('error', onError)
diff --git a/packages/client/ui-plugin-manager/package.json b/packages/client/ui-plugin-manager/package.json
index 1cf5abf9b8..a9eceb424d 100644
--- a/packages/client/ui-plugin-manager/package.json
+++ b/packages/client/ui-plugin-manager/package.json
@@ -55,6 +55,7 @@
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:~",
+ "@deepseek-ai/dsh-api-remote-access": "workspace:*",
"@deepseek-ai/dsh-api-remotes": "workspace:*",
"@deepseek-ai/dsh-client-locale": "workspace:*",
"@deepseek-ai/dsh-client-store": "workspace:*",
diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts
index 52506f9fb7..6682477943 100644
--- a/packages/client/ui-plugin-manager/src/client/index.ts
+++ b/packages/client/ui-plugin-manager/src/client/index.ts
@@ -16,6 +16,8 @@ import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+// Type-only: the ctx.remoteAccess Context merge.
+import type {} from '@deepseek-ai/dsh-api-remote-access/client'
// Type-only: the ctx.remote Context merge and the forwarded-event key face.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
// Type-only: the forwarded events' own declaration (`$on`'s key face resolves
@@ -79,12 +81,13 @@ export const NS = 'pluginManager'
/** The id shared by the sidebar entry and the main panel it opens. */
export const PANEL_ID = 'plugins' as MainPanelId
-/** Services required by the sidebar registration and the Remote methods; the inventory says whether the Host manages a profile. */
-export const inject = ['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout']
+/** Services required by the sidebar registration, the Remote methods, and access; the inventory says whether the Host manages a profile. */
+export const inject = ['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout', 'remoteAccess']
/**
* Contribute the Plugins entry to the sidebar with the management page it
- * opens, and keep it current on the Host's change events.
+ * opens, and keep it current on the Host's change events, while the current
+ * Peer may list the installed bundles.
* @param ctx - the browser plugin context.
*/
export function apply(ctx: ClientContext): void {
@@ -93,82 +96,77 @@ export function apply(ctx: ClientContext): void {
const controller = new PluginManagerController(ctx)
ctx.effect(() => () => { controller.dispose() }, 'ui-plugin-manager: controller')
ctx.provide('pluginChanges', { bundle: packageName => controller.observeChanges(packageName) })
- // The Host says when what is installed, enabled, or composed changed — from
- // this page, the CLI, or another browser — and streams install output.
- ctx.effect(() => {
- // A page never rendered holds no snapshot to refresh.
- const refresh = (): void => {
- // A profile change can replace a bundle's files, and a restarted Host is read afresh.
- controller.details.reset()
- if (controller.getSnapshot().status !== 'idle') void controller.load()
- }
- const disposers = [
- ctx.remote.$on('plugin-manager/changed', refresh),
- ctx.remote.$on('plugin-manager/install-log', (chunk) => { controller.appendLog(chunk) }),
- ctx.remote.$on('plugin-manager/install-state', (progress) => { controller.installProgress(progress) }),
- ctx.remote.$on('plugin-manager/update-state', (progress) => { controller.updateProgress(progress) }),
- ctx.on('connection/reset', refresh),
- ]
- return () => { for (const dispose of disposers) dispose() }
- }, 'ui-plugin-manager: host invalidations')
-
// The page is a global panel: it belongs to the profile, not to a Session,
// and the sidebar's entry selects it. What is installed and switched on is
// the page's own; a plugin's configuration arrives through the slots the
// page declares here, so the page never names a configurable plugin.
const configLedger = configLedgerSource(ctx)
const face = controller.inject(configLedger, tabLedgerSource(ctx), text => ctx.locale.resolveText(text))
- ctx.slots.inject('shell.overlay', () => ctx.slots.register({
- name: 'shell.overlay', id: 'plugin-manager.refresh-toast', locale: NS,
- inject: (): PluginRefreshToastFace => ({
- hooks: { pluginManager: face.hooks.pluginManager },
- dismissNotice: face.dismissNotice,
- }),
- }, PluginRefreshToast))
- ctx.slots.inject('main', function* () {
- const handle = createNavigationStore(), instance = handle.create()
- const store: typeof handle = { ...handle, create: () => instance }
- yield ctx.slots.register({
- name: 'main',
- key: PANEL_ID,
+ ctx.remoteAccess.whenAccess('pluginManager/listBundles', function* () {
+ // The Host says when what is installed, enabled, or composed changed — from
+ // this page, the CLI, or another browser — and streams install output.
+ // A page never rendered holds no snapshot to refresh.
+ const refresh = (): void => {
+ // A profile change can replace a bundle's files, and a restarted Host is read afresh.
+ controller.details.reset()
+ if (controller.getSnapshot().status !== 'idle') void controller.load()
+ }
+ yield ctx.remote.$on('plugin-manager/changed', refresh)
+ yield ctx.remote.$on('plugin-manager/install-log', (chunk) => { controller.appendLog(chunk) })
+ yield ctx.remote.$on('plugin-manager/install-state', (progress) => { controller.installProgress(progress) })
+ yield ctx.remote.$on('plugin-manager/update-state', (progress) => { controller.updateProgress(progress) })
+ yield ctx.on('connection/reset', refresh)
+ yield ctx.slots.inject('shell.overlay', () => ctx.slots.register({
+ name: 'shell.overlay', id: 'plugin-manager.refresh-toast', locale: NS,
+ inject: (): PluginRefreshToastFace => ({
+ hooks: { pluginManager: face.hooks.pluginManager },
+ dismissNotice: face.dismissNotice,
+ }),
+ }, PluginRefreshToast))
+ yield ctx.slots.inject('main', function* () {
+ const handle = createNavigationStore(), instance = handle.create()
+ const store: typeof handle = { ...handle, create: () => instance }
+ yield ctx.slots.register({
+ name: 'main',
+ key: PANEL_ID,
+ locale: NS,
+ store,
+ inject: () => face,
+ children: {
+ 'plugins.add.actions': { kind: 'list', scope: 'root' },
+ 'plugins.tab': { kind: 'list', scope: 'root' },
+ 'plugins.item': { kind: 'list', scope: 'root' },
+ 'plugins.bundle.activation': { kind: 'keyed', scope: 'root' },
+ 'plugins.bundle.config': { kind: 'keyed', scope: 'root' },
+ 'plugins.row.config': { kind: 'keyed', scope: 'root' },
+ 'plugins.detail.actions': { kind: 'list', scope: 'root' },
+ 'plugins.detail.badge': { kind: 'list', scope: 'root' },
+ 'plugins.detail.section': { kind: 'list', scope: 'root' },
+ },
+ }, PluginManagerPage)
+ yield ctx.layout.panelInfo.subscribe(() => {
+ if (ctx.layout.panelInfo.getSnapshot().activePanelId !== PANEL_ID) instance.actions.setView({ kind: 'list' })
+ })
+ const disposeNavigation = ctx.reflect.provide('pluginNavigation', {
+ openList: () => {
+ ctx.layout.selectPanel(PANEL_ID)
+ instance.actions.setTab('installed')
+ instance.actions.setView({ kind: 'list' })
+ },
+ openBundle: (packageName: string) => {
+ ctx.layout.selectPanel(PANEL_ID)
+ instance.actions.setTab('installed')
+ instance.actions.setView({ kind: 'package', name: packageName })
+ },
+ })
+ yield () => { void disposeNavigation() }
+ })
+ yield ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
+ name: 'sidebar.panellist',
+ id: PANEL_ID,
+ order: 0,
+ label: () => t('panel'),
locale: NS,
- store,
- inject: () => face,
- children: {
- 'plugins.add.actions': { kind: 'list', scope: 'root' },
- 'plugins.tab': { kind: 'list', scope: 'root' },
- 'plugins.item': { kind: 'list', scope: 'root' },
- 'plugins.bundle.activation': { kind: 'keyed', scope: 'root' },
- 'plugins.bundle.config': { kind: 'keyed', scope: 'root' },
- 'plugins.row.config': { kind: 'keyed', scope: 'root' },
- 'plugins.detail.actions': { kind: 'list', scope: 'root' },
- 'plugins.detail.badge': { kind: 'list', scope: 'root' },
- 'plugins.detail.section': { kind: 'list', scope: 'root' },
- },
- }, PluginManagerPage)
- yield ctx.layout.panelInfo.subscribe(() => {
- if (ctx.layout.panelInfo.getSnapshot().activePanelId !== PANEL_ID) instance.actions.setView({ kind: 'list' })
- })
- const disposeNavigation = ctx.reflect.provide('pluginNavigation', {
- openList: () => {
- ctx.layout.selectPanel(PANEL_ID)
- instance.actions.setTab('installed')
- instance.actions.setView({ kind: 'list' })
- },
- openBundle: (packageName: string) => {
- ctx.layout.selectPanel(PANEL_ID)
- instance.actions.setTab('installed')
- instance.actions.setView({ kind: 'package', name: packageName })
- },
- })
- yield () => { void disposeNavigation() }
+ }, PluginsPanelIcon))
})
- ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
- name: 'sidebar.panellist',
- id: PANEL_ID,
- order: 0,
- label: () => t('panel'),
- locale: NS,
- }, PluginsPanelIcon))
-
}
diff --git a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx
index 83f72145d7..3a62e8c5ce 100644
--- a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx
+++ b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx
@@ -8,7 +8,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { ILayout, PanelInfo } from '@deepseek-ai/dsh-client-ui-layout/client'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
-import { allowAllAccess, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
+import { allowAllAccess, StaticRemoteAccess, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import * as settings from '@deepseek-ai/dsh-client-ui-settings/client'
import { apply, inject, NS, PANEL_ID } from '../src/client/index.ts'
import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx'
@@ -21,10 +21,11 @@ usePinnedBrowserLanguages('zh-CN')
beforeEach(() => { localStorage.clear() })
afterEach(cleanup)
-async function bench() {
+async function bench(allows: (action: string) => boolean = () => true) {
const ctx = new Context()
onTestFinished(async () => { await ctx.fiber.dispose() })
await ctx.plugin(SlotRegistry).await()
+ new StaticRemoteAccess(ctx, allows)
const locale = new LocaleRuntime(ctx)
ctx.provide('locale', locale)
class LocaleHolder extends Service {
@@ -153,7 +154,20 @@ describe('ui-plugin-manager browser plugin', () => {
})
it('declares only the services the page and its Remote methods use', () => {
- expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout'])
+ expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout', 'remoteAccess'])
+ })
+
+ it('contributes the entry, page, and navigation only while the current Peer may list bundles, and observes changes either way', async () => {
+ const allows = vi.fn(() => false)
+ const b = await bench(allows)
+ declare(b.slots)
+ const fiber = b.ctx.plugin({ inject: [...inject], apply })
+ await fiber.await()
+ expect(allows).toHaveBeenCalledWith('pluginManager/listBundles')
+ for (const name of ['main', 'shell.overlay', 'sidebar.panellist'] as const) expect(b.slots.entries(name)).toEqual([])
+ expect(b.ctx.get('pluginNavigation')).toBeUndefined()
+ expect(b.ctx.get('pluginChanges')?.bundle('dsh-any').getSnapshot()).toBe(false)
+ await fiber.dispose()
})
it('registers the sidebar entry and its page, which reads the Host only once rendered and follows Host changes', async () => {
diff --git a/packages/client/ui-schedule/package.json b/packages/client/ui-schedule/package.json
index dce1c30814..5d149a8d39 100644
--- a/packages/client/ui-schedule/package.json
+++ b/packages/client/ui-schedule/package.json
@@ -54,6 +54,7 @@
"@deepseek-ai/cordis": "workspace:~"
},
"devDependencies": {
+ "@deepseek-ai/dsh-api-remote-access": "workspace:*",
"@deepseek-ai/dsh-api-remotes": "workspace:*",
"@deepseek-ai/dsh-api-session-controller": "workspace:*",
"@deepseek-ai/dsh-api-workspace-controller": "workspace:*",
diff --git a/packages/client/ui-schedule/src/client/index.ts b/packages/client/ui-schedule/src/client/index.ts
index 4aa83ae1a8..8c6dcb3c5f 100644
--- a/packages/client/ui-schedule/src/client/index.ts
+++ b/packages/client/ui-schedule/src/client/index.ts
@@ -38,6 +38,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
import type {} from '@deepseek-ai/dsh-client-ui-tool/client'
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
+import type {} from '@deepseek-ai/dsh-api-remote-access/client'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type {} from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -73,16 +74,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
}
}
-/** Required services for catalogs, ambient Session marks, the right Sidebar, Remote queries, and original-Session navigation. */
+/** Required services for catalogs, ambient Session marks, the right Sidebar, Remote queries, original-Session navigation, and access. */
export const inject = [
'slots', 'locale', 'remote', 'remote.schedule', 'conversation', 'uiConversation', 'uiWorkspace', 'sessions',
- 'workspaces', 'sidebarRightTabs', 'sidebarRight',
+ 'workspaces', 'sidebarRightTabs', 'sidebarRight', 'remoteAccess',
]
/**
* Register the Host task page, the Session-header reminder catalog, the
* right-Sidebar page that shows one task's detail, and the transcript card of
- * one created task.
+ * one created task while the current Peer may read the task catalog.
* @param ctx - browser services used by these contributions.
*/
export function apply(ctx: ClientContext): void {
@@ -90,157 +91,159 @@ export function apply(ctx: ClientContext): void {
const managerDictionaries = { zh: managerZh, en: managerEn, ja: managerJa }
ctx.effect(() => ctx.locale.register(MANAGER_NS, managerDictionaries), 'ui-schedule: manager dictionaries')
const t = ctx.locale.bind(MANAGER_NS)
- const manager: CatalogInjected = createCatalogSource({
- list: () => ctx.remote.schedule.catalog(),
- remove: async (id) => {
- const record = manager.hooks.catalog.getSnapshot().records.find(item => item.id === id)
- if (record === undefined) return { ok: true, value: { id, deleted: false, code: 'schedule_not_found' } }
- return ctx.remote.schedule.delete({ sessionId: record.sessionId, id })
- },
- subscribeChanged: listener => ctx.remote.$on('schedule/changed', listener),
- subscribeReset: listener => ctx.on('connection/reset', listener),
- })
- // One outcome store behind one `shell.overlay` entry announces every deletion
- // this plugin's surfaces settle, so the notice outlives the panel, popover, or
- // tab the deletion started in.
- const deleteToast = createDeleteToastSource()
- const reportedDelete = (onDelete: (id: ScheduleId) => Promise) =>
- async (id: ScheduleId): Promise