Merge remote-tracking branch 'origin/master' into perf/tui-resume-scan

# Conflicts:
#	packages/ui/tui/README.i18n.yaml
This commit is contained in:
Turtle
2026-08-03 21:16:51 +08:00
86 changed files with 981 additions and 199 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md
2026-07-30-multi-select-custom-answer-composition.md: 581beec89a0f0018ec2df687f5dfe1b1b5b86d22
2026-07-30-multi-select-custom-answer-composition.zh.md: 5c9cb59822aca3fbf49fbbdf522c76f963df3480

View File

@@ -0,0 +1,25 @@
# Agent Note: Multi-select custom answer composition
Status: implemented
English | [中文](2026-07-30-multi-select-custom-answer-composition.zh.md)
## Problem
The user-interaction result vocabulary carries selected option labels and optional custom text in separate fields, but its original semantics made them mutually exclusive for every question. On a multi-select question, opening or typing the custom answer discarded labels the user had already selected. The TUI returned only the custom text, and the Web host rejected a client response that preserved both fields.
## Decision
For a question with `multiSelect: true`, one answer item may contain both a non-empty `selected` array and non-empty `custom` text. Web drafts preserve both values regardless of whether the user selects an option or types custom text first; the TUI retains pending custom text across option/custom mode switches and projects it with checked labels from either submit mode; and the Web host accepts the combined response after applying its existing id, label, uniqueness, batch, and non-empty-text validation.
Single-select and optionless questions keep exclusive semantics: custom text overrides any selected option. The result shape remains `{ id, selected, custom? }`, so no wire or tool-output schema changes.
## Alternatives considered
**Encode custom text as another `selected` label.** Rejected because it would erase the distinction between caller-provided option labels and human-authored text, weakening validation and forcing consumers to infer which value was custom.
**Allow `selected` and `custom` together for every question.** Rejected because a single-select question represents one answer; permitting a selected option plus custom text would make its cardinality ambiguous. The combined form is limited to questions that explicitly opt into multiple answers.
## Consequences
Multi-select UIs can represent the user's complete answer without discarding either source. Providers and consumers retain the existing DTO, while request-aware validators interpret the allowed combination from `multiSelect`. Web component and assembled-browser coverage, TUI coverage, host-response coverage, and tool-projection coverage pin the combined result. Web, TUI, and tool-projection coverage also retain labels-only answers; assembled keyless TUI coverage pins the combined terminal flow, and single-select host coverage pins the remaining exclusivity rule.

View File

@@ -0,0 +1,25 @@
# Agent Note: 多选题自定义答案组合
Status: implemented
[English](2026-07-30-multi-select-custom-answer-composition.md) | 中文
## 问题
用户交互结果的词汇分别通过不同字段携带选中的选项标签和可选的自定义文本但最初的语义要求每个问题的这两个字段互斥。对于多选题打开自定义答案或输入文本会丢弃用户已选中的标签。TUI 只返回自定义文本,而 Web 宿主会拒绝同时保留两个字段的客户端响应。
## 决策
对于 `multiSelect: true` 的问题,一个回答项可以同时包含非空 `selected` 数组与非空 `custom` 文本。无论用户先选择选项还是先输入自定义文本Web 草稿都会保留两个值TUI 在选项与自定义模式之间切换时会保留待提交的自定义文本并在任一模式提交时将其与已勾选的标签一同投影Web 宿主则在应用现有的 id、标签、唯一性、批次和非空文本校验后接受组合响应。
单选题和无选项问题仍保持互斥语义:自定义文本会覆盖任何已选中的选项。结果形状仍为 `{ id, selected, custom? }`,因此协议或工具输出 schema 均无需变更。
## 考虑过的替代方案
**把自定义文本编码为另一个 `selected` 标签。** 不予采纳,因为这样会抹去调用方提供的选项标签与用户填写文本之间的区别,削弱校验,并迫使消费方推断哪个值属于自定义内容。
**允许所有问题同时使用 `selected` 与 `custom`。** 不予采纳,因为单选题只表示一个回答;允许选中选项与自定义文本并存会使其基数含义模糊。组合形式仅适用于显式选择多项回答的问题。
## 后果
多选 UI 可以完整表达用户的回答,不会丢弃任一来源。提供方和消费方继续使用现有 DTO而请求感知的校验器会根据 `multiSelect` 判断是否允许组合。Web 组件与组装浏览器的覆盖率、TUI 覆盖率、宿主响应覆盖率和工具投影覆盖率共同固定组合结果。Web、TUI 与工具投影覆盖率还固定了仅含标签的回答形态;组装后的无密钥 TUI 覆盖率固定终端中的组合回答流程,单选题的宿主覆盖率则固定其余的互斥规则。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md
2026-07-30-versioned-gui-welcome-onboarding.md: 8155838f3b6b50f3474ef6c30065ad0d79e6f8af
2026-07-30-versioned-gui-welcome-onboarding.zh.md: c221a6d663af60b03757f135045961bcbcdd0da7
2026-07-30-versioned-gui-welcome-onboarding.md: 4707769d4fa9fbf184e09a2e73087dfd326070be
2026-07-30-versioned-gui-welcome-onboarding.zh.md: c9d2e6274c476c59abc077aa3255ffe57a8a48bc

View File

@@ -14,15 +14,15 @@ The GUI's credential onboarding begins with a DeepSeek-specific readiness check,
**Ownerless product onboarding belongs to `ui-settings-general`.** `src/onboarding-copy.ts` is the single editable source for the complete notice, the Continue label, and `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese owner copy. Runtime locale dictionaries derive their welcome values from that file, and tests import the same owner instead of repeating paragraph text. The notice is browser UI only: it creates no Session event and contributes no model-visible content. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out.
**Acknowledgement is durable per Harness profile.** The Host half registers a `ui-onboarding` section in the user-settings seam, stored under the active `$DSH_HOME/settings.yaml`. The browser shows the notice unless `welcomeNoticeVersion` equals the owner constant exactly. Continue applies one path mutation with the current version and calls `complete()` only after the Host commits it; a failed write leaves the notice open, and closing the page or process writes nothing. Bumping the constant intentionally makes every profile acknowledge the revised copy once.
**Loopback acknowledgement is durable per Harness profile.** The Host half registers a `ui-onboarding` section in the user-settings seam, stored under the active `$DSH_HOME/settings.yaml`. The connection plugin publishes whether the current page uses a loopback authority as `ctx.connection.isLoopback`; hostname classification remains internal to the connection package, and other client plugins consume the service state instead of importing its implementation. A loopback browser shows the notice unless `welcomeNoticeVersion` equals the owner constant exactly. Continue applies one path mutation with the current version and calls `complete()` only after the Host commits it; a failed write leaves the notice open, and closing the page or process writes nothing. Bumping the constant intentionally makes every profile acknowledge the revised copy once. A non-loopback browser must not call the loopback-only settings API. It presents the same notice, but explicit Continue completes the step only in the current browser process; reload or a new process presents it again.
**Concurrent views converge without stale replacement.** The acknowledgement write omits `expectedRevision` deliberately: every tab writes the same version to one path, so the operation is idempotent and preserves sibling fields instead of rebuilding the section. `settings/document-updated` becomes `host/settings-changed`; an already mounted tab refetches and advances when another tab or an external editor commits the current version. The API proxy exposes this one product namespace through a closed allowlist beside configurable-provider namespaces, without treating its changes as model-catalog invalidations.
**Concurrent loopback views converge without stale replacement.** The acknowledgement write omits `expectedRevision` deliberately: every loopback tab writes the same version to one path, so the operation is idempotent and preserves sibling fields instead of rebuilding the section. `settings/document-updated` becomes `host/settings-changed`; an already mounted loopback tab refetches and advances when another tab or an external editor commits the current version. The API proxy exposes this one product namespace through a closed allowlist beside configurable-provider namespaces, without treating its changes as model-catalog invalidations.
**Onboarding temporarily owns the viewport as one continuous stage.** A solid product surface replaces the complete application view through a body-level portal and marks the underlying app root inert; the exact required mask remains mounted behind that surface with `position:absolute`, zero left/right/bottom offsets, `top:80px`, `rgba(0, 0, 0, 0.24)`, and `backdrop-filter: blur(2px)`. Welcome and conditional credential setup render as successive pages in this stage instead of independent modals. Both pages reuse the Web UI's black `BrandWordmark`. The welcome page preserves the four authored paragraphs verbatim under the `内测声明` title; every paragraph uses one 16/28 body scale, and only the requested action clause inside the final paragraph receives a subtle 500 weight. A short staggered opacity/vertical entrance supplies pacing without blocking interaction and disappears under reduced motion. The title receives initial focus, Continue is the sole button, and no close, Escape, or mask-click path exists.
## Alternatives considered
**Browser local storage** — rejected because acknowledgement would follow one browser profile rather than `$DSH_HOME`; a fresh Harness profile could incorrectly inherit a prior acknowledgement, and external profile edits would have no authoritative update stream.
**Browser local storage** — rejected because acknowledgement would follow one browser profile rather than `$DSH_HOME`; a fresh Harness profile could incorrectly inherit a prior acknowledgement, and external profile edits would have no authoritative update stream. Non-loopback fallback therefore remains process-local rather than browser-profile-local.
**A second independent modal in `ui-settings-general`** — rejected because list registrants would still stack whenever welcome and credential readiness were both true. Ordered ownership belongs to the shell that declares and renders the list.
@@ -32,4 +32,4 @@ The GUI's credential onboarding begins with a DeepSeek-specific readiness check,
## Consequences
A fresh profile always sees the welcome notice before provider-specific onboarding; an already configured credential skips only the later DeepSeek step. Reloading after Continue stays past the acknowledged version, changing the owner version presents it again, and closing before Continue leaves the next launch unchanged. Focused store and React tests pin exact-version comparison, write failure, sole-action behavior, no-dismiss paths, coordinator ordering, conditional DeepSeek transfer, and HMR cleanup. The real Chromium scenario boots the shipped Web composition with an isolated harness home, verifies the exact mask geometry and computed styles, reloads before and after acknowledgement, continues into missing-credential setup, confirms an acknowledged-version mismatch returns while the credential is configured, and checks the browser console.
A fresh profile always sees the welcome notice before provider-specific onboarding; an already configured credential skips only the later DeepSeek step. On loopback, reloading after Continue stays past the acknowledged version, changing the owner version presents it again, and closing before Continue leaves the next launch unchanged. On non-loopback, Continue advances the live process without a privileged settings request and reload presents the notice again. Focused store and React tests pin both persistence modes, exact-version comparison, write failure, sole-action behavior, no-dismiss paths, coordinator ordering, conditional DeepSeek transfer, and HMR cleanup. The real Chromium scenario boots the shipped Web composition with an isolated harness home, verifies the exact mask geometry and computed styles, reloads before and after acknowledgement, continues into missing-credential setup, confirms an acknowledged-version mismatch returns while the credential is configured, and checks the browser console.

View File

@@ -14,15 +14,15 @@ GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测
**不属于单一功能的产品引导由 `ui-settings-general` 持有。** `src/onboarding-copy.ts` 是完整通知、「继续」按钮文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源GUI 支持的两种 locale 都有意渲染同一份中文所有者文案。运行时 locale 字典从该文件派生欢迎文案,测试也导入同一个所有者,而不重复段落文本。该通知只存在于浏览器 UI它不会创建会话事件也不会贡献任何模型可见内容。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。
**确认状态按 Harness profile 持久化。** 宿主端在 user-settings seam 中注册 `ui-onboarding` 分节,并存入当前 `$DSH_HOME/settings.yaml`。除非 `welcomeNoticeVersion` 与文案所有者文件中的常量精确相等,否则浏览器会显示通知。「继续」会以当前版本执行一次路径变更,并且仅在宿主端提交成功后调用 `complete()`;写入失败时通知保持打开,关闭页面或进程则不会写入任何内容。提升该常量会有意要求每个 profile 对修订后的文案重新确认一次。
**loopback 确认状态按 Harness profile 持久化。** 宿主端在 user-settings seam 中注册 `ui-onboarding` 分节,并存入当前 `$DSH_HOME/settings.yaml`connection 插件通过 `ctx.connection.isLoopback` 统一发布当前页面是否使用 loopback authorityhostname 判定函数留在 connection 包内,其他客户端插件只消费服务状态,不跨插件导入实现函数。除非 `welcomeNoticeVersion` 与文案所有者文件中的常量精确相等,否则 loopback 浏览器会显示通知。「继续」会以当前版本执行一次路径变更,并且仅在宿主端提交成功后调用 `complete()`;写入失败时通知保持打开,关闭页面或进程则不会写入任何内容。提升该常量会有意要求每个 profile 对修订后的文案重新确认一次。非 loopback 浏览器不能调用仅限 loopback 的 settings API它仍显示同一通知但显式点击「继续」只会在当前浏览器进程中完成该步骤重新加载或新进程会再次显示通知。
**并发视图无需陈旧的整体替换即可收敛。** 确认写入有意省略 `expectedRevision`:每个标签页都向同一路径写入相同版本,因此该操作是幂等的,并会保留同级字段,而不是重建整个分节。`settings/document-updated` 会转为 `host/settings-changed`另一个标签页或外部编辑器提交当前版本后已挂载的标签页会重新拉取状态并推进。API 网关在可配置提供方 namespace 之外,通过封闭的允许列表暴露这一个产品 namespace同时不会把它的变更视为模型目录失效事件。
**并发 loopback 视图无需陈旧的整体替换即可收敛。** 确认写入有意省略 `expectedRevision`:每个 loopback 标签页都向同一路径写入相同版本,因此该操作是幂等的,并会保留同级字段,而不是重建整个分节。`settings/document-updated` 会转为 `host/settings-changed`;另一个标签页或外部编辑器提交当前版本后,已挂载的 loopback 标签页会重新拉取状态并推进。API 网关在可配置提供方 namespace 之外,通过封闭的允许列表暴露这一个产品 namespace同时不会把它的变更视为模型目录失效事件。
**引导流程会暂时接管视口,形成一个连续阶段。** 纯色产品界面通过挂载到 `body` 的 portal 取代完整的应用视图,并将底层应用根节点标记为 inert严格符合要求的遮罩仍挂载在该界面后方并保留 `position:absolute`、left/right/bottom 偏移量为零、`top:80px``rgba(0, 0, 0, 0.24)``backdrop-filter: blur(2px)`。欢迎页和按条件显示的凭据设置页在这一阶段中依次呈现,而不是各自作为独立的模态窗口。两个页面都复用 Web UI 的黑色 `BrandWordmark`。欢迎页在 `内测声明` 标题下逐字保留既定的四段文案;所有段落统一采用 16/28 的正文字号与行高,只有最后一段中指定的行动语句使用较为克制的 500 字重。短暂的错落式透明度与纵向位移动画营造出舒缓节奏但不会阻碍交互并会在用户启用减少动态效果时禁用。初始焦点落在标题上「继续」是唯一按钮且不存在关闭、Escape 或点击遮罩的退出路径。
## 曾考虑的替代方案
**浏览器本地存储**:不予采用,因为确认状态会跟随某个浏览器 profile而不是 `$DSH_HOME`;全新的 Harness profile 可能错误继承此前的确认状态,外部 profile 编辑也没有权威更新流。
**浏览器本地存储**:不予采用,因为确认状态会跟随某个浏览器 profile而不是 `$DSH_HOME`;全新的 Harness profile 可能错误继承此前的确认状态,外部 profile 编辑也没有权威更新流。因此,非 loopback 的回退保持为进程内状态,而不是浏览器 profile 状态。
**在 `ui-settings-general` 中再增加一个独立模态窗口**不予采用因为欢迎通知和凭据就绪状态同时为真时list 注册方仍会堆叠。声明并渲染该 list 的外壳应当持有有序所有权。
@@ -32,4 +32,4 @@ GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测
## 后果
全新 profile 始终会在提供方专用引导之前看到欢迎通知;凭据已经配置时,只会跳过后续 DeepSeek 步骤。点击「继续」后重新加载不会再次显示已确认版本,更改文案所有者文件中的版本值会让通知重新出现,而确认前关闭窗口不会改变下次启动。针对性的 store 与 React 测试固化了精确版本比较、写入失败、单一操作、不可关闭路径、协调器顺序、按条件移交 DeepSeek 步骤和 HMR热模块替换清理行为。真实 Chromium 场景会使用隔离的 harness 家目录启动随产品提供的 Web 组合,验证遮罩的精确几何尺寸和计算样式,在确认前后分别重新加载,继续进入凭据缺失设置流程,确认凭据已配置时确认版本不匹配仍会使通知重新出现,并检查浏览器控制台。
全新 profile 始终会在提供方专用引导之前看到欢迎通知;凭据已经配置时,只会跳过后续 DeepSeek 步骤。在 loopback 上,点击「继续」后重新加载不会再次显示已确认版本,更改文案所有者文件中的版本值会让通知重新出现,而确认前关闭窗口不会改变下次启动。在非 loopback 上,「继续」会在不发起受保护 settings 请求的情况下推进当前进程,重新加载则再次显示通知。针对性的 store 与 React 测试固化了两种持久化模式、精确版本比较、写入失败、单一操作、不可关闭路径、协调器顺序、按条件移交 DeepSeek 步骤和 HMR热模块替换清理行为。真实 Chromium 场景会使用隔离的 harness 家目录启动随产品提供的 Web 组合,验证遮罩的精确几何尺寸和计算样式,在确认前后分别重新加载,继续进入凭据缺失设置流程,确认凭据已配置时确认版本不匹配仍会使通知重新出现,并检查浏览器控制台。

View File

@@ -180,6 +180,20 @@ export function parseReferences({ body, repository }) {
}
}
/**
* Retain only references that resolve to Issues rather than pull requests.
* @param {{all: number[], resolving: number[], related: number[]}} references Parsed references.
* @param {Map<number, unknown>} issues Resolved same-repository Issues.
* @returns {{all: number[], resolving: number[], related: number[]}} Issue-only references.
*/
export function retainIssueReferences(references, issues) {
return {
all: references.all.filter((number) => issues.has(number)),
resolving: references.resolving.filter((number) => issues.has(number)),
related: references.related.filter((number) => issues.has(number)),
}
}
/**
* Validate one Issue with its Project status.
* @param {{title: string, body: string, assignees: string[], labels: string[], type: string|null, priority: string|null, status: string|null, state: string, stateReason: string|null}} issue Issue snapshot.
@@ -472,7 +486,7 @@ async function pullRequestSnapshot(number) {
reviewRequestCount: reviewRequests.users.length + reviewRequests.teams.length,
reviewCount: reviews.length,
labels: pull.labels.map((label) => label.name),
references,
references: retainIssueReferences(references, issues),
issues,
}
}

View File

@@ -4,6 +4,7 @@ import test from 'node:test'
import {
countVisibleUnits,
parseReferences,
retainIssueReferences,
requiresPullRequestPolicy,
validateBody,
validateIssue,
@@ -117,6 +118,24 @@ test('separates resolving and informational references', () => {
)
})
test('does not treat pull request references as Issue associations', () => {
const references = {
all: [123, 1180, 1181],
resolving: [123, 1180],
related: [1181],
}
const issues = new Map([
[1180, {}],
[1181, {}],
])
assert.deepEqual(retainIssueReferences(references, issues), {
all: [1180, 1181],
resolving: [1180],
related: [1181],
})
})
test('allows informational references without cross-object constraints', () => {
const errors = validatePullRequest({
isDraft: false,

58
.github/workflows/issue-lifecycle.yml vendored Normal file
View File

@@ -0,0 +1,58 @@
name: Issue lifecycle
on:
issues:
types:
- opened
- edited
- assigned
- unassigned
- labeled
- unlabeled
- closed
- reopened
- field_added
- field_removed
pull_request:
types:
- opened
- edited
- synchronize
- reopened
- labeled
- unlabeled
- ready_for_review
- review_requested
pull_request_review:
types:
- submitted
permissions:
contents: read
concurrency:
group: issue-lifecycle-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}
cancel-in-progress: false
jobs:
lifecycle:
name: Issue lifecycle
runs-on: ubuntu-latest
steps:
- name: Check out trusted policy
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Create project token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.DSH_ISSUE_APP_CLIENT_ID }}
private-key: ${{ secrets.DSH_ISSUE_APP_PRIVATE_KEY }}
owner: deepseek-harness
repositories: deepseek-harness
- name: Handle repository event
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: node .github/issue-management/policy.mjs lifecycle

27
.github/workflows/issue-policy.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: Issue policy
on:
pull_request:
types: [opened, edited, synchronize, reopened, labeled, unlabeled, ready_for_review, review_requested]
pull_request_review:
types: [submitted]
permissions:
contents: read
issues: read
pull-requests: read
jobs:
policy:
name: Issue policy
runs-on: ubuntu-latest
steps:
- name: Check out trusted policy
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Validate pull request
env:
GITHUB_TOKEN: ${{ github.token }}
run: node .github/issue-management/policy.mjs pr

View File

@@ -117,8 +117,19 @@ class ScriptedTuiAdapter extends LlmAdapter {
const blocks = lastMessage?.content ?? []
if (blocks.some(block => block.type === 'tool-result')) {
const answered = blocks.some(block => block.type === 'tool-result' && block.toolCallId === BASH_FAILURE_CALL_ID)
for (const chunk of textChunks(answered ? BASH_FAILURE_TEXT : FINAL_TEXT)) yield chunk
const answeredBash = blocks.some(block =>
block.type === 'tool-result' && block.toolCallId === BASH_FAILURE_CALL_ID)
if (answeredBash) {
for (const chunk of textChunks(BASH_FAILURE_TEXT)) yield chunk
return
}
const toolResultText = blocks.flatMap(block => block.type === 'tool-result'
? block.content.flatMap(content => content.type === 'text' ? [content.text] : [])
: []).join('\n')
if (toolResultText !== '{"answers":[{"id":"mode","selected":["Safe"],"custom":"Release notes"}]}') {
throw new Error(`the scripted TUI request received an unexpected question answer: ${toolResultText}`)
}
for (const chunk of textChunks(FINAL_TEXT)) yield chunk
return
}
if (lastText.includes(BASH_FAILURE_PROBE)) {
@@ -140,6 +151,7 @@ class ScriptedTuiAdapter extends LlmAdapter {
id: 'mode',
header: 'Execution mode',
question: 'How should the scripted run proceed?',
multi_select: true,
options: [
{ label: 'Safe', description: 'Use the guarded path.' },
{ label: 'Fast', description: 'Use the shorter path.' },

View File

@@ -259,6 +259,7 @@ const SELECT_PRO_MODEL = [
{ waitFor: 'scripted TUI ready.', send: '/model\r' },
{ waitFor: 'Select model', send: '\x1b[B\x1b[Z\r' },
] as const
const ANSWER_MULTI_WITH_CUSTOM = ' \tRelease notes\r'
describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
it.each([
@@ -450,7 +451,10 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
// The question text first appears in the streamed tool-call card. Wait
// for the dialog's input legend so Enter cannot arrive before it owns
// terminal input when pre-dispatch policy yields.
{ waitFor: 'Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt', send: '\r' },
{
waitFor: 'Tab custom answer • ↑/↓ navigate • Space toggle • Enter submit • Esc interrupt',
send: ANSWER_MULTI_WITH_CUSTOM,
},
{ waitFor: 'Decision received. Scripted TUI run complete.', send: '' },
// Session title: the first user message drives the first-message-llm
// provider's tool-less title call; the scripted adapter answers it, the
@@ -476,6 +480,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
expect(output).not.toContain('\u001B[999CMODEL_CURSOR')
expect(output).not.toContain('\u009B31mMODEL_C1')
expect(output).toContain('Safe')
expect(output).toContain('Release notes')
expect(output).toContain('\u001B]0;scripted session title — DeepSeek Harness\u0007')
expect(output).toContain('Session status')
expect(output).toContain('Title')
@@ -861,7 +866,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
actions: [
...SELECT_PRO_MODEL,
{ waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: 'exercise the TUI\r' },
{ waitFor: 'How should the scripted run proceed?', send: '\r' },
{ waitFor: 'How should the scripted run proceed?', send: ANSWER_MULTI_WITH_CUSTOM },
{ waitFor: 'Decision received. Scripted TUI run complete.', send: '/exit\r' },
],
inspect: async (cwd) => { context = await readLoggedRequestContext(cwd) },

View File

@@ -6,10 +6,10 @@
// layers, per-plugin CSS injection, and a rendered journey reaching chat
// content from the keyless FixtureApiClient transport.
//
// Behavior assertions do NOT belong here: component and wiring behavior is
// pinned by the per-package suites (SlotTestRuntime benches over src), which
// this smoke's plugin set cannot influence — bundling, module-table
// resolution, and boot layering are the only failure modes left to it.
// Component behavior remains owned by per-package suites (SlotTestRuntime
// benches over src). This smoke additionally pins the resident approval
// fixture's cross-plugin projection because only the built connection/runtime/
// workspace graph can prove that transport-to-row path end to end.
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
@@ -105,8 +105,17 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
await within(tree).findByText('4 sessions')
// The resident approval fixture proves the assembled workspace plugin
// distinguishes a blocked running session from an ordinarily busy one.
const waitingTitle = await within(tree).findByText('Fixture 历史会话')
const waitingRow = waitingTitle.closest<HTMLElement>('[role="treeitem"]')
if (waitingRow === null) throw new Error('fixture Session title must belong to a tree row')
expect(waitingRow.querySelector('[data-state="warning"]')).not.toBeNull()
expect(waitingRow.querySelector('[data-state="ongoing"]')).toBeNull()
within(waitingRow).getByText('Waiting for approval')
// Opening a session reaches chat content through the fixture transport.
fireEvent.click(await within(tree).findByText('Fixture 历史会话'))
fireEvent.click(waitingTitle)
await waitFor(() => {
expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
}, { timeout: 10_000 })

View File

@@ -23,15 +23,16 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/question-composer', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
// Second golden: the answered transcript — the question resolved into its
// tool round trip and the final reply, the state the waiting golden cannot see.
const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md')
// Final golden: the answered transcript — the question resolved into its tool
// round trip and the final reply, the state the composer goldens cannot see.
const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md')
const MODE = webSnapshotMode()
// The options carry long descriptions on purpose: the squeeze assertion below
// needs option copy that WRAPS, which is the only shape that reproduces a
// collapsed row painting its copy outside its own box.
const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." After I answer, reply with the single word DONE and stop.'
const PROMPT = 'Use the ask_user_question tool to ask me exactly one multi-select question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." Set multi_select to true. After I answer, reply with the single word DONE and stop.'
describe('web e2e: resident question composer round trip', () => {
let scaffold: WebScaffold
@@ -124,9 +125,17 @@ describe('web e2e: resident question composer round trip', () => {
await page.setViewportSize(original)
}
await composer.getByRole('radio', { name: 'Blue' }).click()
// Submit: Enter on the focused option (the composer's documented submit).
await composer.getByRole('radio', { name: 'Blue' }).press('Enter')
const blue = composer.getByRole('checkbox', { name: 'Blue' })
await blue.click()
const custom = composer.getByRole('textbox')
await custom.fill('Include accessibility notes')
expect(await blue.getAttribute('aria-checked')).toBe('true')
expect(await custom.inputValue()).toBe('Include accessibility notes')
if (MODE !== 'record') {
const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
await compareOrRefreshGolden(COMPOSED_EXPECTED, snapshot, MODE)
}
await custom.press('Enter')
const sessionId = await settled
if (MODE === 'record') {
@@ -135,7 +144,14 @@ describe('web e2e: resident question composer round trip', () => {
}
// World state: the tool result carries the chosen answer, and DONE lands.
const results = sessionEvents.filter(e => e.type === 'tool/result')
expect(JSON.stringify(results.at(-1))).toContain('Blue')
const answerText = results.flatMap(event => event.data.message.content.flatMap(block =>
block.type === 'tool-result'
? block.content.filter(item => item.type === 'text').map(item => item.text)
: [],
)).at(-1)
expect(JSON.parse(answerText ?? '')).toEqual({
answers: [{ id: 'color', selected: ['Blue'], custom: 'Include accessibility notes' }],
})
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// Composer gone; regular input restored.
expect(await page.locator('[data-question-key]').count()).toBe(0)
@@ -149,6 +165,11 @@ describe('web e2e: resident question composer round trip', () => {
}, 200_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md', 'answered.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.jsonl',
'ui.expected.md',
'composed.expected.md',
'answered.expected.md',
])
})
})

View File

@@ -0,0 +1,53 @@
// Trusted non-loopback Web access must not wedge on the loopback-only
// settings API while the mandatory product notice owns the viewport.
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import {
acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE } from './support.ts'
import { WELCOME_NOTICE_COPY } from '@deepseek-ai/dsh-client-ui-settings-general'
const MODE = webSnapshotMode()
describe.skipIf(MODE === 'record')('web e2e: remote welcome notice', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({ remoteAuthority: 'remote.localhost', welcomeNoticePending: true })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('#root', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('advances process-locally and presents the notice again after reload', async () => {
const welcome = page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title })
await welcome.waitFor({ timeout: 15_000 })
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true)
await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
await expect.poll(
() => page.locator('#root').evaluate(root => (root as HTMLElement).inert),
{ timeout: 15_000 },
).toBe(false)
const reloadWarnings = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
acknowledgeReloadConnectionLoss(tripwire, reloadWarnings)
await welcome.waitFor({ timeout: 15_000 })
expect(tripwire.warnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
})

View File

@@ -89,7 +89,7 @@ const REPLAY_PROVIDERS = [{
export interface WebScaffold {
/** The active snapshot mode this scaffold booted under. */
mode: WebSnapshotMode
/** Browser-facing origin (http://127.0.0.1:<bound port>). */
/** Browser-facing origin for the bound test server. */
baseUrl: string
/** Settled root context (the in-process barrier seam; headless event subscription is its sanctioned use). */
ctx: Context
@@ -166,6 +166,12 @@ export interface LaunchOptions {
}
/** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */
welcomeNoticePending?: boolean
/**
* Browse through a trusted non-loopback hostname that the browser resolves
* to loopback (for example `*.localhost`). The test server stays bound to
* 127.0.0.1; a non-resolving authority fails before Host trust is exercised.
*/
remoteAuthority?: string
}
/** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */
@@ -185,6 +191,7 @@ async function cleanupScaffoldWorld(ctx: Context, workspaceCwd: string, persiste
export async function launchWebScaffold(options: LaunchOptions = {}): Promise<WebScaffold> {
requireDist()
const mode = webSnapshotMode()
const browserHost = options.remoteAuthority ?? '127.0.0.1'
if (mode === 'record') {
// Both owning vitest configs (web unconditionally, snapshot in record
// mode) load the repo-root .env before this file runs.
@@ -261,7 +268,13 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// to the production OTLP endpoint (or whatever DSH_TELEMETRY_OTLP_URL
// names in the ambient environment).
{ id: 'telemetry-otel', disabled: true },
{ id: 'webserver', config: { host: '127.0.0.1', port: 0, distIndex: DIST_INDEX } },
{
id: 'webserver',
config: { host: '127.0.0.1', port: 0, distIndex: DIST_INDEX },
},
...options.remoteAuthority === undefined
? []
: [{ id: 'connection', config: { trustedHosts: [options.remoteAuthority] } }],
{ id: 'settings', config: { dshHome: harnessHome } },
{ id: 'credentials', config: { dshHome: harnessHome } },
// The shipped directory-picker row is the -auto chooser, which resolves
@@ -352,7 +365,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
return {
harnessHome,
mode,
baseUrl: `http://127.0.0.1:${port}`,
baseUrl: `http://${browserHost}:${port}`,
ctx,
workspaceCwd,
persistenceRoot,

View File

@@ -4,7 +4,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: "Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop. {{clock}}"
- text: "Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop. {{clock}}"
- button "Copy":
- img
- button "Branch into a new conversation" [disabled]:

View File

@@ -0,0 +1,17 @@
- region "Which color do you prefer?":
- text: Pick one
- heading "Which color do you prefer?" [level=2]
- button "Dismiss all questions":
- img
- group:
- checkbox "Blue" [checked]: Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.
- checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.
- textbox "Type your answer": Include accessibility notes
- button "Previous question" [disabled]:
- img
- text: 1 / 1
- button "Next question" [disabled]:
- img
- status
- button "Skip this question"
- button "Submit"

View File

@@ -1,20 +1,20 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785150167878,"cwd":"{{cwd}}/workspace"}
{"type":"turn/start","seq":0,"time":1785150167924,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
{"type":"user/message","seq":1,"time":1785150167925,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"user/message","seq":1,"time":1785150167925,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1785150167927,"data":{"title":"Use the ask_user_question tool to","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1785150167928,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1785150167929,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1785150168452,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":6,"time0":1785150168452,"data":{"turn":1,"step":1,"index":0,"dt":[87,26,1,0,0,0,38,0,0,0,0,1,12,27,0,27,0,0,1,25,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," ask","_user","_","question"," tool"," with"," specific"," parameters","."," Let"," me"," do"," exactly"," that","."]}}
{"type":"assistant/chunk","seq":28,"time":1785150168775,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"tool-call-chunks","seq0":29,"time0":1785150168776,"data":{"turn":1,"step":1,"index":1,"dt":[25,1,0,0,0,25,0,0,0,26,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,1,25,0,0,0,0,1,25,1,0,0,0,0,25,1,0,0,26,0,0,1,0,24,1,0,0,0,1,26,1,0,0,0,0,25,0,1,0,0,0,25,1,0,0,25,0,0,0,0,1,25,0,0,1,0,0,25,0,0,0,1,0,26,1,24],"id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","color","\","," \"","question","\":"," \"","Which"," color"," do"," you"," prefer","?\","," \"","header","\":"," \"","Pick"," one","\","," \"","options","\":"," [","{\"","label","\":"," \"","Blue","\","," \"","description","\":"," \"","A"," cool"," recessive"," hue"," that"," reads"," as"," calm"," and"," trustworthy"," in"," long"," reading"," sessions"," and"," dense"," dash","boards",".\"","},"," {\"","label","\":"," \"","Green","\","," \"","description","\":"," \"","A"," rest","ful"," mid","-spect","rum"," hue"," with"," the"," highest"," perceived"," brightness",","," easiest"," on"," the"," eye"," over"," long"," sessions",".\"","}]","}]","}"]}}
{"type":"tool-call-chunks","seq0":29,"time0":1785150168776,"data":{"turn":1,"step":1,"index":1,"dt":[25,1,0,0,0,25,0,0,0,26,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,1,25,0,0,0,0,1,25,1,0,0,0,0,25,1,0,0,26,0,0,1,0,24,1,0,0,0,1,26,1,0,0,0,0,25,0,1,0,0,0,25,1,0,0,25,0,0,0,0,1,25,0,0,1,0,0,25,0,0,0,1,0,26,1,24],"id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","color","\","," \"","question","\":"," \"","Which"," color"," do"," you"," prefer","?\","," \"","header","\":"," \"","Pick"," one","\", \"multi_select\": true,"," \"","options","\":"," [","{\"","label","\":"," \"","Blue","\","," \"","description","\":"," \"","A"," cool"," recessive"," hue"," that"," reads"," as"," calm"," and"," trustworthy"," in"," long"," reading"," sessions"," and"," dense"," dash","boards",".\"","},"," {\"","label","\":"," \"","Green","\","," \"","description","\":"," \"","A"," rest","ful"," mid","-spect","rum"," hue"," with"," the"," highest"," perceived"," brightness",","," easiest"," on"," the"," eye"," over"," long"," sessions",".\"","}]","}]","}"]}}
{"type":"assistant/chunk","seq":127,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."}}}}
{"type":"assistant/chunk","seq":128,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}}}
{"type":"assistant/chunk","seq":128,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"multi_select\": true, \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}}}
{"type":"assistant/chunk","seq":129,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}}}}
{"type":"assistant/chunk","seq":130,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":131,"time":1785150169311,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"}
{"type":"tool/call","seq":132,"time":1785150169312,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}
{"type":"tool/result","seq":133,"time":1785150169787,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"]}]}"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"}
{"type":"assistant/message","seq":131,"time":1785150169311,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"multi_select\": true, \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"}
{"type":"tool/call","seq":132,"time":1785150169312,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"multi_select\": true, \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}
{"type":"tool/result","seq":133,"time":1785150169787,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"],\"custom\":\"Include accessibility notes\"}]}"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"}
{"type":"step/end","seq":134,"time":1785150169790,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":135,"time":1785150169790,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":136,"time":1785150170605,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}

View File

@@ -3,9 +3,9 @@
- heading "Which color do you prefer?" [level=2]
- button "Dismiss all questions":
- img
- radiogroup:
- radio "Blue": 1 Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.
- radio "Green": 2 Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.
- group:
- checkbox "Blue": Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.
- checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.
- textbox "Type your answer"
- button "Previous question" [disabled]:
- img

View File

@@ -400,6 +400,17 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
const tree = page.getByRole('tree', { name: 'Subagent sessions' })
const nestedRow = tree.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) })
expect(await nestedRow.locator(':scope > *').count()).toBe(1)
const clickArea = nestedRow.locator(':scope > *')
const [treeBox, clickAreaBox] = await Promise.all([
tree.boundingBox(),
clickArea.boundingBox(),
])
expect(treeBox).not.toBeNull()
expect(clickAreaBox).not.toBeNull()
expect([
Math.round(clickAreaBox!.x - treeBox!.x),
Math.round(treeBox!.x + treeBox!.width - clickAreaBox!.x - clickAreaBox!.width),
]).toEqual([5, 5])
await compareOrRefreshGolden(
BRANCHLESS_EXPECTED,
await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd),

View File

@@ -36,6 +36,7 @@
"tests/settings-chrome.e2e.ts",
"tests/models-settings.e2e.ts",
"tests/onboarding-deepseek-config.e2e.ts",
"tests/remote-welcome.e2e.ts",
"tests/workspace-management.e2e.ts",
"tests/replay-round-trip.e2e.ts",
"tests/hmr-live.e2e.ts",

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/core-data-structures/user-interaction.md
user-interaction.md: 2483382eee07d379b13d456149096c8afab70e4b
user-interaction.zh.md: 57f57277975597fff712705cc741ae8e78643642
user-interaction.md: 16478168c8fcfeafbd3fdbbf30e732de81a3a8dd
user-interaction.zh.md: 7c8baaf189d759ca609c1d51652120f973ae48b6

View File

@@ -86,14 +86,14 @@ interface AskUserQuestionRequest {
## Answer
Providers return one answer item per question id. `selected` contains selected option labels, and `custom` carries a free-form "Other" answer when the user typed one. When `custom` is present, `selected` is empty; custom text is an answer override, not a supplement to selected choices. A UI may also use an item with empty `selected` and no `custom` to preserve a skipped question in an otherwise completed batch.
Providers return one answer item per question id. `selected` contains selected option labels, and `custom` carries a free-form "Other" answer when the user typed one. For a single-select question, `custom` overrides the selected choice and `selected` is empty. For a multi-select question, `custom` may supplement the labels in `selected`. A UI may also use an item with empty `selected` and no `custom` to preserve a skipped question in an otherwise completed batch.
```ts type-equiv
/** Answer to one question. */
interface AskUserQuestionAnswerItem {
/** The answered question id. */
id: string
/** Selected option labels. Empty for custom or unanswered choices. */
/** Selected option labels. May accompany custom text for a multi-select question. */
selected: string[]
/** Optional free-text "Other" answer. */
custom?: string

View File

@@ -86,14 +86,14 @@ interface AskUserQuestionRequest {
## 回答
提供方为每个问题 id 返回一个回答项。`selected` 包含选中的选项标签,`custom` 在用户输入自由文本时携带「其他」回答。`custom` 存在时,`selected` 为空;自定义文本是对选中项的覆盖,而非补充。UI 也可以使用 `selected` 为空且不含 `custom` 的回答项,在其余问题均已完成的批次中保留被跳过的问题。
提供方为每个问题 id 返回一个回答项。`selected` 包含选中的选项标签,`custom` 在用户输入自由文本时携带「其他」回答。对于单选题,`custom` 会覆盖选中的选项,且 `selected` 为空。对于多选题,`custom` 可以补充 `selected` 中的标签。UI 也可以使用 `selected` 为空且不含 `custom` 的回答项,在其余问题均已完成的批次中保留被跳过的问题。
```ts type-equiv
/** Answer to one question. */
interface AskUserQuestionAnswerItem {
/** The answered question id. */
id: string
/** Selected option labels. Empty for custom or unanswered choices. */
/** Selected option labels. May accompany custom text for a multi-select question. */
selected: string[]
/** Optional free-text "Other" answer. */
custom?: string

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
README.md: c8b7c4787cbcbf6a202fb944459a589fcadd7c8d
README.zh.md: 693420183ffa4fb20e1fecbff523a12261a45d45
README.md: f537fee3273e3b5d2411197cf1a1a6e0d34af5f9
README.zh.md: a29d2c00e7df3f6290a03ffdad59b70b43702aca

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Loopback hostname classification stays package-internal: the `/api` Host fence uses it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
## /api browser-trust fence

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam以及循环的 sink配置类型。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory``host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法而这些方法在真正的认证层出现之前仍只限回环本机。平台子类WebApiClient/FixtureApiClient、ConnectionController 循环和 fixture 数据源都属于包内部apply 负责选择并驱动它们,测试则通过 src 访问。契约api-contracts v3 §3。
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam以及循环的 sink配置类型。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory``host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法而这些方法在真正的认证层出现之前仍只限回环本机。平台子类WebApiClient/FixtureApiClient、ConnectionController 循环和 fixture 数据源都属于包内部apply 负责选择并驱动它们,测试则通过 src 访问。契约api-contracts v3 §3。
## /api 浏览器信任栅栏

View File

@@ -14,6 +14,7 @@
*/
import type { IncomingHttpHeaders } from 'node:http'
import { isLoopbackHostname } from './loopback-hostname.ts'
/** The request facts the fence reads (structural subset of IncomingMessage). */
interface ApiTrustRequest {
@@ -25,14 +26,6 @@ function header(headers: IncomingHttpHeaders, name: string): string | undefined
return typeof value === 'string' ? value : undefined
}
function isLoopbackHostname(hostname: string): boolean {
if (hostname === 'localhost' || hostname === '[::1]') return true
const parts = hostname.split('.')
return parts.length === 4
&& parts[0] === '127'
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
}
/** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */
function parseAuthority(authority: string): URL | undefined {
try {

View File

@@ -8,6 +8,7 @@ import type { IApiClient } from './api.ts'
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
import { FixtureApiClient } from './fixture.ts'
import { WebApiClient } from './web-api-client.ts'
import { isLoopbackHostname } from '../loopback-hostname.ts'
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
export type {
@@ -48,6 +49,8 @@ export const inject: string[] = []
export interface ConnectionHandle {
/** Shared api client (fixture or real, decided at boot from the page URL). */
readonly api: IApiClient
/** Whether the current page authority is loopback; non-browser contexts default to true. */
readonly isLoopback: boolean
/**
* Start the connect/pump/reconnect loop with the consumer's frame sinks.
* One consumer owns the streams (the runtime object layer); a second call
@@ -64,11 +67,13 @@ export interface ConnectionHandle {
* @param ctx - client cordis context.
*/
export function apply(ctx: Context): void {
const fixture = typeof location !== 'undefined' && new URLSearchParams(location.search).has('fixture')
const pageLocation = typeof location === 'undefined' ? undefined : location
const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture')
const api: IApiClient = fixture ? new FixtureApiClient() : new WebApiClient()
let started = false
const handle: ConnectionHandle = {
api,
isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname),
start(sinks, config) {
if (started) throw new Error('connection: the stream loop is already owned by another consumer')
started = true

View File

@@ -0,0 +1,18 @@
/**
* Browser-safe, zero-dependency loopback classification shared by the `/api`
* Host fence and the package's `ctx.connection` state. The predicate stays
* package-internal; client plugins consume the derived state through Cordis.
*/
/**
* Whether a normalized URL hostname names the local loopback authority.
* @param hostname - WHATWG URL hostname (IPv6 literals retain brackets).
* @returns true for localhost, IPv6 loopback, or any IPv4 address in 127/8.
*/
export function isLoopbackHostname(hostname: string): boolean {
if (hostname === 'localhost' || hostname === '[::1]') return true
const parts = hostname.split('.')
return parts.length === 4
&& parts[0] === '127'
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
}

View File

@@ -8,7 +8,7 @@ import { apply, type ConnectionHandle } from '../src/client/index.ts'
import { FixtureApiClient } from '../src/client/fixture.ts'
import { WebApiClient } from '../src/client/web-api-client.ts'
type Win = { location?: { search: string } }
type Win = { location?: { hostname: string; search: string } }
afterEach(() => {
delete (globalThis as Win).location
@@ -24,20 +24,28 @@ async function mount(): Promise<ConnectionHandle> {
describe('connection client apply', () => {
it('mounts ctx.connection with the real client when no ?fixture switch is present', async () => {
;(globalThis as Win).location = { search: '' }
;(globalThis as Win).location = { hostname: 'localhost', search: '' }
const handle = await mount()
expect(handle.api).toBeInstanceOf(WebApiClient)
expect(handle.isLoopback).toBe(true)
})
it('selects the fixture client under ?fixture (and with no location at all stays real)', async () => {
;(globalThis as Win).location = { search: '?fixture' }
;(globalThis as Win).location = { hostname: '127.0.0.1', search: '?fixture' }
expect((await mount()).api).toBeInstanceOf(FixtureApiClient)
delete (globalThis as Win).location
expect((await mount()).api).toBeInstanceOf(WebApiClient)
const handle = await mount()
expect(handle.api).toBeInstanceOf(WebApiClient)
expect(handle.isLoopback).toBe(true)
})
it('reports non-loopback page authority through the connection handle', async () => {
;(globalThis as Win).location = { hostname: '192.0.2.20', search: '' }
expect((await mount()).isLoopback).toBe(false)
})
it('start() hands out one loop, rejects a second consumer, and stop() aborts the streams', async () => {
;(globalThis as Win).location = { search: '?fixture' }
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
const handle = await mount()
// config omitted: the `config ?? {}` default arm is part of the surface.
const loop = handle.start({})
@@ -46,7 +54,7 @@ describe('connection client apply', () => {
})
it('WebApiClient carries requests over globalThis.fetch', async () => {
;(globalThis as Win).location = { search: '' }
;(globalThis as Win).location = { hostname: 'localhost', search: '' }
const handle = await mount()
const original = globalThis.fetch
const seen: string[] = []

View File

@@ -0,0 +1,18 @@
/** Shared loopback-hostname semantics for the Host fence and browser UI. */
import { describe, expect, it } from 'vitest'
import { isLoopbackHostname } from '../src/loopback-hostname.ts'
describe('isLoopbackHostname', () => {
it('accepts localhost, IPv6 loopback, and the whole IPv4 127/8 block', () => {
for (const hostname of ['localhost', '[::1]', '127.0.0.1', '127.8.9.10', '127.255.255.255']) {
expect(isLoopbackHostname(hostname)).toBe(true)
}
})
it('refuses malformed and non-loopback hostnames', () => {
for (const hostname of ['remote.localhost', '::1', '128.0.0.1', '127.0.0', '127.0.0.256', '127.0.0.-1']) {
expect(isLoopbackHostname(hostname)).toBe(false)
}
})
})

View File

@@ -26,6 +26,7 @@ async function mount(): Promise<Bench> {
const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 }
const handle: ConnectionHandle = {
api,
isLoopback: true,
start: (sinks) => {
bench.sinks = sinks
return { stop: () => { bench.stopped += 1 } }

View File

@@ -20,6 +20,7 @@ async function mount(): Promise<Bench> {
const bench: Bench = { ctx, sinks: undefined }
const handle: ConnectionHandle = {
api,
isLoopback: true,
start: (sinks) => {
bench.sinks = sinks
return { stop: () => {} }

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: e1dbe7d4d5992b6b5b029fddfc9d9857ccae7443
README.zh.md: fd82ad65f8e903a6f7106e8b8ff8eccbf1435957
README.md: 78572ba0ab3ce9475dba31dee8844017564e2a18
README.zh.md: 7708e980e24f4ea4365fbbacd641a5be6c61b138

View File

@@ -10,7 +10,7 @@ The resident conversation shell survives no-session and session transitions. Wit
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager tracks this approval wait through the `waitingApproval` list bit even for uninstantiated sessions; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.

View File

@@ -32,7 +32,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
工具行同样是 slot独立工具环`ToolViewRegistry``ctx.toolviews`outlet已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位Session scopekey 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openFile``ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seamapply 在聊天注册后挂载 ConversationService因此服务存在即可保证 slot 已声明bash 示例是第三方姿态的范例。Trajectory/waterfall瀑布式事件工具视图 slot 共享此形状并随各自的渲染点落地RendersCheck 会拒绝没有任何渲染方的声明)。
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流问题ui-question与审批ApprovalPanel都经编辑器接管作答不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数key 缺席即隐藏 chipchip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用取消、Escape、关闭按钮与点击遮罩都不会提交命令。
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 通过 `waitingApproval` 列表位跟踪这种审批等待,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流问题ui-question与审批ApprovalPanel都经编辑器接管作答不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数key 缺席即隐藏 chipchip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用取消、Escape、关闭按钮与点击遮罩都不会提交命令。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key摘要该次调用「试图写入」的内容从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock``order: 0` 占用 `'conversation.input.dock'` 列表 slot位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock包括这条计划条。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-question/README.md
README.md: 5ebba2a1da6e6108b82e9deb235b84f987600345
README.zh.md: 0aa6428a9b6472fc5b525c11b4716ebc50c378c3
README.md: 72d94396771eec0a90b96008b1fd5e4a736a398c
README.zh.md: 3c2b12b30dd2858c7b8f99193829c3274f3f8228

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Web `ask_user_question` feature plugin. Its host half mounts `dsh-tool-ask-user` only when the Web feature is selected; its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot.
The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. 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 its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
A request whose single question declares a presentation intent renders as that intent's own surface instead. `plan-review` — set by `dsh-plan-mode` on the `exit_plan_mode` review — takes the waiting-approval card shape: a `Plan review` strip, the plan as the scrolling markdown body, the question text as the card's accessible name, and one decision row of `Chat about it` / `Refuse` / `Approve`. Approve and Refuse answer with the asker's own option labels (the intent names which label approves, so the verdict never rides option order) and keep the asker's descriptions as tooltips; `Chat about it` rejects the wait as `ASK_CANCELLED`, returning the composer so the user can say what they want instead. The card claims a request only when it can send every answer that request allows: one question, the intent declared, the plan present as `detail`, the named approve label offered, and a binary single choice (at most one option besides approve, not multi-select). Anything else — no intent, a batch of several questions, a missing plan, an approve label naming no option, a third option, a multi-select decision — stays on the generic flow, which can express it. An intent changes the layout, never which answers are reachable.

View File

@@ -4,7 +4,7 @@
Web `ask_user_question` 功能插件。只有选择 Web 功能时,其主机侧才会挂载 `dsh-tool-ask-user`;浏览器侧会把 `question` 配置项注册到会话拥有的 `conversation.composer` 键控 slot 中。
组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信内容策略。封顶卡片保持标题、导航与提交动作固定超长的详情与选项共享内部滚动区。单选选项会立即前进所有问题均已回答或跳过后Enter 会提交IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected``custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信内容策略。封顶卡片保持标题、导航与提交动作固定超长的详情与选项共享内部滚动区。单选选项会立即前进所有问题均已回答或跳过后Enter 会提交IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
若某个请求的唯一问题声明了呈现意图,则改为渲染该意图自己的界面。`plan-review` —— 由 `dsh-plan-mode``exit_plan_mode` 审阅上设置 —— 采用等待审批卡片的形状:一条 `Plan review` 条带、计划作为可滚动的 markdown 主体、问题文本作为卡片的无障碍名称,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。Approve 与 Refuse 用提问方自己的选项标签回答(意图指名哪个标签表示批准,因此裁决绝不依赖选项顺序),并把提问方的描述保留为 tooltip`Chat about it``ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。卡片只在能够发出该请求允许的每一个答案时才接管:只有一个问题、声明了意图、计划以 `detail` 存在、提供了被指名的批准标签,且是二元单选(除批准外最多一个选项,且非多选)。其他任何情形 —— 没有意图、一批含多个问题、缺少计划、批准标签未命中任何选项、出现第三个选项、多选决定 —— 都留在能够表达它的通用流程上。意图改变的只是布局,从不改变可达的答案。

View File

@@ -98,12 +98,13 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
const choose = (label: string): void => {
updateDraft((current) => {
const selected = question.multiSelect === true
? current.selected.includes(label)
if (question.multiSelect === true) {
const selected = current.selected.includes(label)
? current.selected.filter(item => item !== label)
: [...current.selected, label]
: [label]
return { selected, custom: '', skipped: false }
return { ...current, selected, skipped: false }
}
return { selected: [label], custom: '', skipped: false }
})
if (question.multiSelect !== true && index < questions.length - 1) {
setIndex(current => current + 1)
@@ -129,7 +130,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
const custom = value.custom.trim()
return {
id: item.id,
selected: custom === '' ? value.selected : [],
selected: custom === '' || item.multiSelect === true ? value.selected : [],
...(custom === '' ? {} : { custom }),
}
}),
@@ -155,14 +156,17 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
submitDrafts(drafts)
}
// Shared by the inline custom input and the optionless textarea: typing a
// custom draft clears any selection, and Enter continues the flow
// (Shift+Enter stays a newline in the textarea; on the single-line input it
// is inert either way).
// Shared by the inline custom input and the optionless textarea: a
// multi-select draft retains checked labels, while a single-select custom
// answer replaces its selection. Enter continues the flow (Shift+Enter
// stays a newline in the textarea; on the single-line input it is inert).
const draftCustom = (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
const value = event.target.value
updateDraft(current => ({
...current, selected: [], custom: value, skipped: false,
...current,
selected: question.multiSelect === true ? current.selected : [],
custom: value,
skipped: false,
}))
}

View File

@@ -104,13 +104,19 @@ describe('QuestionComposer', () => {
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('checkbox', { name: '代码质量' }))
fireEvent.keyDown(screen.getByRole('checkbox', { name: '代码质量' }), { key: 'Enter' })
const multiCustom = screen.getByPlaceholderText('输入你的答案')
fireEvent.change(multiCustom, { target: { value: '沟通能力' } })
fireEvent.click(screen.getByRole('checkbox', { name: '产品判断' }))
expect(screen.getByRole('checkbox', { name: '系统设计' }).getAttribute('aria-checked')).toBe('true')
expect(screen.getByRole('checkbox', { name: '代码质量' }).getAttribute('aria-checked')).toBe('true')
expect((multiCustom as HTMLInputElement).value).toBe('沟通能力')
fireEvent.keyDown(multiCustom, { key: 'Enter' })
// The domain face encoded the whole batch into one carrier envelope.
expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [
{ id: 'profile', selected: ['工程落地型 (Recommended)'] },
{ id: 'detail', selected: [], custom: '要能独立排查线上问题' },
{ id: 'signals', selected: ['系统设计', '代码质量'] },
{ id: 'signals', selected: ['系统设计', '代码质量', '产品判断'], custom: '沟通能力' },
]))
expect(screen.getByRole<HTMLButtonElement>('button', { name: '正在提交…' }).disabled).toBe(true)
})
@@ -233,6 +239,11 @@ describe('QuestionComposer', () => {
fireEvent.keyDown(custom, { key: 'Enter' })
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('button', { name: '提交' }))
expect(respond).toHaveBeenNthCalledWith(1, answeredEnvelope('second', [
{ id: 'profile', selected: ['工程落地型 (Recommended)'] },
{ id: 'detail', selected: [], custom: 'x' },
{ id: 'signals', selected: ['系统设计'] },
]))
expect(await screen.findByText('网络中断')).toBeTruthy()
expect(screen.getByRole<HTMLButtonElement>('button', { name: '提交' }).disabled).toBe(false)

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md
README.md: 4dbd339c93171b330895ab66366e76fd06013704
README.zh.md: 8ad6de99ce78d3bdb1e7b35e872e5bfe6790e758
README.md: 0202d596f509feeba39a38254e8bab2fae27b649
README.zh.md: adec73edda00d34e209772f0bcc54a994f593997

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages.
`src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam; the browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out.
`src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out.
## Model Experience

View File

@@ -4,7 +4,7 @@
设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。
`src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在 user-settings seam 中注册 `ui-onboarding`浏览器比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后无需重新加载即可推进。版本不同时系统会有意重新显示通知。欢迎页保留原文的每个段落仅强调最后一段中指定的句段初始焦点落在标题上并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。
`src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在 user-settings seam 中注册 `ui-onboarding`。loopback 浏览器比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。非 loopback 浏览器不能访问受保护的 settings API它仍会显示通知但「继续」只推进当前浏览器进程重新加载后会再次显示通知。版本不同时,系统会有意重新显示通知。欢迎页保留原文的每个段落仅强调最后一段中指定的句段初始焦点落在标题上并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。
## 模型体验

View File

@@ -31,7 +31,7 @@ export interface WelcomeNoticeInjected {
export type WelcomeNoticeProps =
PropsRuntime<'settings.onboarding'> & PropsLocale<'settings'> & WelcomeNoticeInjected
/** Render the mandatory notice until its current version commits durably. */
/** Render the mandatory notice until its current version is acknowledged. */
export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode {
const { complete, controller, useSnapshot, t } = props
const state = useSnapshot(snapshot => snapshot)

View File

@@ -61,7 +61,7 @@ export function apply(ctx: ClientContext): void {
// locale/change re-registration wiring.
const t = ctx.locale.bind(NS)
const connection = ctx.get('connection') as ConnectionHandle
const welcomeController = new WelcomeNoticeStore(connection.api)
const welcomeController = new WelcomeNoticeStore(connection.api, connection.isLoopback ? 'host' : 'memory')
const useWelcomeSnapshot = bindSnapshotSelector(welcomeController.store)
const welcomeInjected = (): WelcomeNoticeInjected => ({
controller: welcomeController,

View File

@@ -1,4 +1,4 @@
/** Durable welcome-notice state over the Host settings document. */
/** Welcome-notice state, durable when the browser may use Host settings. */
import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
@@ -24,7 +24,7 @@ function acknowledgementOf(view: SettingsNamespaceView): string | undefined {
return typeof value === 'string' ? value : undefined
}
/** Coordinates welcome acknowledgement reads and the sole durable write. */
/** Coordinates durable Host acknowledgement or a process-local remote fallback. */
export class WelcomeNoticeStore {
/** uSES-safe state source shared by the registered welcome step. */
readonly store: SnapshotStore<WelcomeNoticeState> = createSnapshotStore({
@@ -33,12 +33,22 @@ export class WelcomeNoticeStore {
private generation = 0
/** @param api - settings wire face used for durable reads and writes. */
constructor(private readonly api: Pick<IApiClient, 'settings'>) {}
/**
* @param api - settings wire face used for durable reads and writes.
* @param persistence - remote browsers use memory because settings is loopback-only.
*/
constructor(
private readonly api: Pick<IApiClient, 'settings'>,
private readonly persistence: 'host' | 'memory' = 'host',
) {}
/** Load the current acknowledgement from the Host settings document. */
/** Load the acknowledgement from Host settings or initialize process-local state. */
async load(): Promise<void> {
const generation = ++this.generation
if (this.persistence === 'memory') {
this.store.update((state) => { state.status = 'ready'; state.error = null })
return
}
this.store.update((state) => { state.status = 'loading'; state.error = null })
try {
const response = await this.api.settings.describe({})
@@ -64,12 +74,20 @@ export class WelcomeNoticeStore {
}
/**
* Persist this copy version. The path mutation is idempotent across tabs and
* preserves every sibling setting; failure leaves the step unacknowledged.
* @returns true only when the Host committed the acknowledgement.
* Acknowledge this copy version. The Host path mutation is idempotent across
* tabs and preserves sibling settings; remote fallback changes only this store.
* @returns true when the selected persistence mode accepted the acknowledgement.
*/
async acknowledge(): Promise<boolean> {
const generation = ++this.generation
if (this.persistence === 'memory') {
this.store.update((state) => {
state.status = 'ready'
state.acknowledged = true
state.error = null
})
return true
}
this.store.update((state) => { state.status = 'saving'; state.error = null })
try {
const response = await this.api.settings.mutate({
@@ -99,7 +117,9 @@ export class WelcomeNoticeStore {
}
/**
* Refresh only after the welcome step has begun reading durable state.
* Refresh only after welcome state has left idle. A memory-mode load retains
* acknowledgement so reconnect and settings-change refreshes do not reopen a
* process-local notice.
* @param controller - welcome state owner whose current status decides whether to load.
*/
export function refreshWelcomeIfLoaded(controller: WelcomeNoticeStore): void {

View File

@@ -25,7 +25,7 @@ const SEATS = [
['settings.onboarding', WelcomeNotice],
] as const
async function bench() {
async function bench(isLoopback = true) {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const locale = new LocaleService(ctx)
@@ -47,7 +47,7 @@ async function bench() {
},
},
}))
ctx.provide('connection', { api: { settings: { describe: settingsDescribe } } } as never)
ctx.provide('connection', { api: { settings: { describe: settingsDescribe } }, isLoopback } as never)
return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe }
}
@@ -159,6 +159,19 @@ describe('ui-settings-general apply', () => {
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(3) })
})
it('keeps remote welcome acknowledgement process-local', async () => {
const b = await bench(false)
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const entry = b.slots.entries('settings.onboarding')[0]!
const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)()
await controller.load()
await expect(controller.acknowledge()).resolves.toBe(true)
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
expect(b.settingsDescribe).not.toHaveBeenCalled()
})
it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => {
const b = await bench()
const redeclare = declare(b.slots)

View File

@@ -30,6 +30,21 @@ function deferred<T>() {
}
describe('WelcomeNoticeStore', () => {
it('acknowledges in memory without calling loopback-only settings APIs', async () => {
const describe = vi.fn()
const mutate = vi.fn()
const controller = new WelcomeNoticeStore({ settings: { describe, mutate } } as never, 'memory')
await controller.load()
expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: false, error: null })
await expect(controller.acknowledge()).resolves.toBe(true)
expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null })
await controller.load()
expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null })
expect(describe).not.toHaveBeenCalled()
expect(mutate).not.toHaveBeenCalled()
})
it('acknowledges only the exact current copy version', async () => {
for (const [version, acknowledged] of [
[undefined, false],

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-sidebar/README.md
README.md: 93a1f15a5802f94a0ebe930dda1dbd4fbc7343c9
README.zh.md: 8c8545a5d7d8cb4d58772abf867d7ee82c31bf1d
README.md: 19c2d1033de4475816249aa8429f4a589eeb6481
README.zh.md: b8c154586570cf1b9fd4bf776bc09b36ab5ee7d2

View File

@@ -22,6 +22,6 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **State dots have two live data states (running/none)** — the done/error/amber sources arrive with P-II approvals and notifications; the four-color primitive is already wired.
- **Session state-dot rendering is owned by [ui-workspace](../ui-workspace/README.md)** — done/error notification sources remain deferred.
- **Group-by menu ships by-workspace only** — Update/Status grouping strategies are drawn without specs and deferred.
- **"New task completed" unread marking is local viewing state** — completion-time > last-seen never reaches the host.

View File

@@ -22,6 +22,6 @@ New Session 会启动运行时的页面局部前端 Session Intent真实 Work
## 已知限制与暂缓事项
- **状态点只有两种实时数据状态running/none**done/error/amber 的数据源将随 P-II 审批与通知功能一并提供;四色原语已接入
- **Session 状态点渲染由 [ui-workspace](../ui-workspace/README.md) 持有**done/error 的通知数据源仍暂缓实现
- **分组选单只提供按 Workspace 分组**Update/Status 分组策略只有图稿而没有规范,暂缓实现。
- **「New task completed」未读标记是本地查看状态**:完成时间 > 上次查看时间这一事实永远不会到达宿主。

View File

@@ -67,7 +67,7 @@
min-width: 0;
}
.menu > .node {
margin-left: -8px;
margin-left: -3px;
}
.row {

View File

@@ -100,17 +100,12 @@
}
.table tbody tr[data-request-only='true'] td {
height: 1px;
height: 0;
padding-top: 0;
padding-bottom: 0;
border-bottom: 0;
}
.table tbody tr[data-request-only='true']:has(+ tr[data-request-only='true']) td {
/* Keep consecutive boundary markers from painting their halos over one another. */
height: 9px;
}
.table tbody tr[data-request-only='true']:last-child td {
/* Retain the lower half of the 16px boundary marker at the table's end. */
height: 9px;
@@ -130,10 +125,12 @@
}
.requestBoundaryControl {
--request-boundary-base-left: 12px;
position: absolute;
z-index: 6;
top: -8px;
left: 12px;
left: calc(var(--request-boundary-base-left) + var(--request-boundary-offset, 0px));
width: 16px;
height: 16px;
padding: 0;
@@ -198,6 +195,12 @@
box-shadow: 0 0 0 1.5px var(--dsw-alias-brand-primary-new-colorprimary-new-color);
}
.requestBoundaryControl[data-request-status='error']::before,
.requestBoundaryControl[data-request-status='error']:hover::before,
.requestBoundaryControl[data-request-status='error']:focus-visible::before {
background: var(--dsw-alias-state-error-primary);
}
.requestBoundaryControl:hover::after,
.requestBoundaryControl:focus-visible::after {
opacity: 1;
@@ -402,7 +405,7 @@
}
.requestBoundaryControl {
left: 6px;
--request-boundary-base-left: 6px;
}
.kindSlot {
@@ -1245,9 +1248,19 @@
background: transparent;
cursor: pointer;
font: 600 12px/18px var(--dsw-font-family);
gap: 2px;
user-select: none;
}
.thinkingChevron {
flex: none;
transition: transform 120ms var(--ds-ease-in-out);
}
.thinkingToggle[aria-expanded='true'] .thinkingChevron {
transform: rotate(90deg);
}
.thinkingToggle:hover {
color: var(--dsw-alias-label-secondary);
}

View File

@@ -191,6 +191,10 @@ type TrajectorySplitStyle = CSSProperties & {
'--trajectory-tool-request-width': string
}
type RequestBoundaryStyle = CSSProperties & {
'--request-boundary-offset': string
}
function clampDetailsWidth(width: number, splitWidth: number): number {
const maxWidth = Math.max(
DETAILS_MIN_WIDTH,
@@ -453,6 +457,22 @@ function indexRequestNumbers(
return numbers
}
function indexRequestBoundaryRuns(records: readonly TableRecord[]): ReadonlyMap<number, number> {
const indexes = new Map<number, number>()
let runLength = 0
for (const record of records) {
if (record.cell.requestOnly === true) {
indexes.set(record.cell.index, runLength++)
continue
}
if (runLength > 0 && record.groupStart && requestStep(record.group) !== undefined) {
indexes.set(record.cell.index, runLength)
}
runLength = 0
}
return indexes
}
function summarizeTurn(records: readonly TableRecord[]): string {
const steps = new Set(
records
@@ -1212,7 +1232,8 @@ function MarkdownRecordContent({
aria-expanded={thinkingExpanded}
onClick={() => { onThinkingExpandedChange(!thinkingExpanded) }}
>
{thinkingExpanded ? 'Thinking' : 'Thinking ...'}
Thinking
<IconChevronRightOutline14 className={css.thinkingChevron} size={12} />
</button>
{thinkingExpanded && (
<MarkdownFragment
@@ -1545,6 +1566,7 @@ export function TrajectoryTable({
collapsedAssistants,
)
: filterRecords(allRecords, searchMatchIndexes)
const requestBoundaryRuns = indexRequestBoundaryRuns(records)
const selected = allRecords.find(record => record.cell.index === selectedIndex)
const selectedPrompt = selected?.cell.kind === 'system'
? selected.cell.promptDetail
@@ -1791,6 +1813,12 @@ export function TrajectoryTable({
const requestInfo = request === undefined
? undefined
: sessionRequestNumbers?.find(candidate => candidate.number === request)
const requestStatus = requestInfo?.status
?? (record.cell.isError === true ? 'error' : undefined)
const requestRunIndex = requestBoundaryRuns.get(record.cell.index) ?? 0
const requestBoundaryStyle: RequestBoundaryStyle = {
'--request-boundary-offset': `${requestRunIndex * 8}px`,
}
const requestLabel = request === undefined
? undefined
: `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}`
@@ -1882,6 +1910,9 @@ export function TrajectoryTable({
aria-label={requestLabel}
aria-pressed={requestSelected}
data-label={requestLabel}
data-request-run-index={requestRunIndex}
data-request-status={requestStatus}
style={requestBoundaryStyle}
onClick={(event) => {
event.stopPropagation()
selectRequest({
@@ -1930,36 +1961,36 @@ export function TrajectoryTable({
<span
className={css.kindSlot}
>
<Tooltip
label={KIND_LABEL[record.cell.kind]}
side="right"
<span
className={`${css.kindTag} ${
record.cell.kind === 'system'
? css.systemNeutral
: record.cell.kind === 'context'
? css.contextGreen
: record.cell.kind === 'compacted'
? css.compacted
: record.cell.kind === 'tool'
? css.toolAmber
: record.cell.kind === 'message'
? css.assistantVioletBright
: record.cell.kind === 'subtool'
? css.subtoolAmber
: css[record.cell.kind]
}`}
data-role-kind={record.cell.kind}
>
<span
className={`${css.kindTag} ${
record.cell.kind === 'system'
? css.systemNeutral
: record.cell.kind === 'context'
? css.contextGreen
: record.cell.kind === 'compacted'
? css.compacted
: record.cell.kind === 'tool'
? css.toolAmber
: record.cell.kind === 'message'
? css.assistantVioletBright
: record.cell.kind === 'subtool'
? css.subtoolAmber
: css[record.cell.kind]
}`}
data-role-kind={record.cell.kind}
<Tooltip
label={KIND_LABEL[record.cell.kind]}
side="right"
>
<span className={css.kindTagIcon} aria-hidden="true">
{KIND_ICON[record.cell.kind]}
</span>
<span className={css.kindTagLabel}>
{KIND_LABEL[record.cell.kind]}
</span>
</Tooltip>
<span className={css.kindTagLabel}>
{KIND_LABEL[record.cell.kind]}
</span>
</Tooltip>
</span>
</span>
)}
</div>
@@ -2497,7 +2528,7 @@ export function TrajectoryTable({
)}
{selectedAssistantRequestTarget !== undefined && (
<OverviewSection
label="Timing"
label="Request Timing"
onOpen={() => {
selectRequest(selectedAssistantRequestTarget, 'timing')
}}

View File

@@ -64,7 +64,7 @@ describe('TrajectoryTable', () => {
it('shows assistant timing facts after keyboard selection', () => {
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
fireEvent.keyDown(screen.getByRole('row', { name: /ASSISTANT/ }), { key: 'Enter' })
fireEvent.click(screen.getByRole('button', { name: 'Timing' }))
fireEvent.click(screen.getByRole('button', { name: 'Request Timing' }))
expect(screen.getByText('500 ms')).toBeTruthy()
expect(screen.getByText('1.00 s')).toBeTruthy()
@@ -101,10 +101,13 @@ describe('TrajectoryTable', () => {
render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
const toggle = screen.getByRole('button', { name: 'Thinking ...' })
const toggle = screen.getByRole('button', { name: 'Thinking' })
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByText(thinking)).toBeNull()
fireEvent.click(toggle)
expect(screen.getByRole('button', { name: 'Thinking' })).toBe(toggle)
expect(toggle.getAttribute('aria-expanded')).toBe('true')
expect(toggle.parentElement?.textContent?.length).toBeGreaterThan(thinking.length)
})
@@ -222,19 +225,79 @@ describe('TrajectoryTable', () => {
expect(errorResult.closest('[class*="errorPayload"]')).toBeTruthy()
})
it('renders responsive role icons with a custom tooltip', () => {
it('marks failed requests and lays coincident request markers left to right', () => {
const turns: readonly TrajectoryTurnModel[] = [
{
turn: 1,
groups: [{
title: 'Step 1',
cells: [{
index: 1,
kind: 'message',
text: '',
requestOnly: true,
isError: true,
timeSeconds: 0.1,
}],
}],
},
{
turn: 2,
groups: [{
title: 'Step 1',
cells: [{
index: 2,
kind: 'message',
text: '',
requestOnly: true,
isError: true,
timeSeconds: 0.1,
}],
}],
},
{
turn: 3,
groups: [{
title: 'Step 1',
cells: [{
index: 3,
kind: 'message',
text: 'Recovered response',
timeSeconds: 0.1,
}],
}],
},
]
render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
const failed = screen.getByRole('button', { name: 'Request #1' })
const retry = screen.getByRole('button', { name: 'Request #2' })
const recovered = screen.getByRole('button', { name: 'Request #3' })
expect(failed.getAttribute('data-request-status')).toBe('error')
expect(failed.getAttribute('data-request-run-index')).toBe('0')
expect(failed.style.getPropertyValue('--request-boundary-offset')).toBe('0px')
expect(retry.getAttribute('data-request-run-index')).toBe('1')
expect(retry.style.getPropertyValue('--request-boundary-offset')).toBe('8px')
expect(recovered.getAttribute('data-request-run-index')).toBe('2')
expect(recovered.style.getPropertyValue('--request-boundary-offset')).toBe('16px')
})
it('shows the custom role tooltip only from the responsive icon', () => {
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
const toolTag = view.container.querySelector<HTMLElement>('[data-role-kind="tool"]')
const toolIcon = toolTag?.querySelector<HTMLElement>('[data-role-icon="wrench"]')
expect(toolTag).not.toBeNull()
expect(toolTag?.getAttribute('title')).toBeNull()
expect(toolTag?.querySelector('[data-role-icon="wrench"]')).toBeTruthy()
expect(toolIcon).toBeTruthy()
fireEvent.mouseEnter(toolTag as HTMLElement)
expect(screen.queryByRole('tooltip')).toBeNull()
fireEvent.mouseEnter(toolIcon as HTMLElement)
const tooltip = screen.getByRole('tooltip')
expect(tooltip.textContent).toBe('TOOL')
expect(tooltip.getAttribute('data-side')).toBe('right')
fireEvent.mouseLeave(toolTag as HTMLElement)
fireEvent.mouseLeave(toolIcon as HTMLElement)
expect(screen.queryByRole('tooltip')).toBeNull()
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
README.md: 2670bdfa2fb1a223bf0c0ea65fbacc1cfb30c607
README.zh.md: 1e68cfb1a94c240c32949059ca6a3c6adc25208b
README.md: 17105f9d70ab5fa0c0472c4b3fb39b759107f469
README.zh.md: b40b9469271e539501a8f6fc0b70a84f8961f7ab

View File

@@ -12,6 +12,8 @@ Workspace and Session hover cards copy the value their row clips: activating a W
The Session row's Fork action forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child; a trailing ASCII or fullwidth parenthesized number is incremented in the same style, while an unnumbered title gets ` (1)` appended. The source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A fork or rename failure leaves the current selection unchanged; after a rename failure, the created child remains in the list.
Session rows distinguish the runtime's live `waitingApproval` approval-request fact from an otherwise blue in-flight Session: an amber warning dot takes precedence over the running indicator, and the hover card reports **Waiting for approval** until the request is resolved. Every lit state carries a visually hidden label (`Waiting for approval` or `Running`) for assistive technology; an idle row leaves the reserved status slot empty. Question waits do not set a list-level status bit such as `waitingApproval`.
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
The shared sidebar projection hides rows whose durable Session summary has `origin: 'subagent'`; users enter those conversations through the selected parent's subagent header catalog. Ordinary forks remain visible because lineage alone does not set that origin. The runtime keeps hidden rows available for conversation, title, and addressed transport state.
@@ -28,4 +30,5 @@ None; this package neither assembles nor sends a provider request.
- **No fuzzy content search or event deep links** — the content backend uses literal token/phrase matching, and selecting a result opens the Session rather than the matching event.
- **No Session deletion or unarchive control** — archiving replaces the former Delete placeholder; archived sessions have no viewing or unarchive surface yet, and Workspace registration deletion does not delete Sessions.
- **Approval waiting is not aggregated into collapsed groups** — a waiting row inside a collapsed group lights no group-header indicator and becomes visible only after that group is expanded.
- **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow.

View File

@@ -12,6 +12,8 @@ Workspace 和 Session 悬浮卡片会复制对应行被截断的值:激活 Wor
Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。
Session 行会把 runtime 的实时 `waitingApproval` 审批请求状态与原本显示为蓝色的进行中 Session 区分开琥珀色警告点优先于运行指示器hover 卡片则在请求解决前显示**等待审批**。每种点亮状态都带有面向辅助技术的视觉隐藏标签(等待审批或进行中,随词典本地化);空闲行会保留空的状态槽位。问题等待不会设置如 `waitingApproval` 这样的列表级状态位。
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
共享侧边栏投影会隐藏持久化 Session 摘要中带有 `origin: 'subagent'` 的行;用户从所选 parent 的 subagent 页头目录进入这些对话。普通 fork 仍然可见,因为仅有谱系不会设置该 origin。运行时仍保留隐藏行供对话、标题与已寻址传输状态使用。
@@ -28,4 +30,5 @@ Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork
- **没有模糊内容搜索或事件深链接**:内容后端采用字面 token短语匹配选择结果会打开 Session而不是匹配的事件。
- **没有 Session 删除与取消归档控件**:归档取代了原先的 Delete 占位;已归档会话尚无查看或取消归档入口;删除 Workspace 注册记录不会删除 Session。
- **待审批状态不会聚合到折叠的分组上**:折叠分组内正在等待的行不会点亮分组头指示,只有展开该分组后才可见。
- **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture测试前置数据的部署或远程浏览器部署无法打开本地操作系统对话框模态框会显示平台故障并允许重试。可远程的选取是 `-browse` 组合的应用内流程。

View File

@@ -46,6 +46,7 @@ export const zh = {
'actions.newSession.aria': '在“{name}”中新建会话',
'status.running': '进行中',
'status.idle': '空闲',
'status.waitingApproval': '等待审批',
'hover.created': '创建于 {time}',
'hover.copied': '已复制',
'date.ymd': '{y}年{m}月{d}日',
@@ -103,6 +104,7 @@ export const en = {
'actions.newSession.aria': 'New session in {name}',
'status.running': 'Running',
'status.idle': 'Idle',
'status.waitingApproval': 'Waiting for approval',
'hover.created': 'Created {time}',
'hover.copied': 'Copied',
'date.ymd': '{y}-{m}-{d}',

View File

@@ -124,6 +124,15 @@
color: var(--dsw-alias-label-tertiary);
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.folderActive {
color: var(--dsw-alias-state-business-primary);

View File

@@ -12,6 +12,7 @@ import {
IconEllipsisOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
import type { WorkspaceBrowserProps } from '../contract/slots.ts'
import type { GroupNode, SearchResultNode, SessionNode } from '../tree.ts'
import { relativeTime } from '../tree.ts'
@@ -165,16 +166,16 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: {
)
}
/**
* One top-level 34px session row with running dot and relative time.
* @param props.node - derived session node.
* @param props.currentId - selected session id (row highlight).
* @param props.now - epoch ms for relative-time formatting.
* @param props.onOpen - open a session by id.
* @returns the session row.
*/
/** Hover-card body: full title, relative time, and the status line (running/idle until wire status lands). */
/** Session status presentation; approval waiting outranks the underlying running state. */
function sessionStatus(node: SessionNode, t: RowTranslate): { state: StateDotState; label: string } {
if (node.waitingApproval) return { state: 'warning', label: t('status.waitingApproval') }
if (node.running) return { state: 'ongoing', label: t('status.running') }
return { state: 'done', label: t('status.idle') }
}
/** Hover-card body: full title, relative time, and approval/running/idle status. */
function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) {
const status = sessionStatus(node, t)
return (
<div className={css.hoverContent}>
<div className={css.hoverTitle}>{displayTitle(node, t)}</div>
@@ -182,8 +183,8 @@ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number;
before the first prompt. */}
{!node.blank && <div className={css.hoverTime}>{hoverTimeLabel(node.updatedAt, now, t)}</div>}
<div className={css.hoverStatus}>
<StateDot state={node.running ? 'ongoing' : 'done'} />
<span>{node.running ? t('status.running') : t('status.idle')}</span>
<StateDot state={status.state} />
<span>{status.label}</span>
</div>
</div>
)
@@ -248,6 +249,20 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' |
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
}
/**
* One top-level 34px session row: status dot (approval waiting outranks
* running), title, relative time, and the row actions menu.
* @param props.node - derived session node.
* @param props.currentId - selected session id (row highlight).
* @param props.now - epoch ms for relative-time formatting.
* @param props.onOpen - open a session by id.
* @param props.onRename - open the session rename dialog (id + current title).
* @param props.onFork - fork a session at its last completed turn.
* @param props.onArchive - archive a session by id.
* @param props.drag - optional draggable-row wiring.
* @param props.t - the browser root's locale seat.
* @returns the session row.
*/
export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, drag, t }: {
node: SessionNode
currentId: string | undefined
@@ -266,6 +281,7 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
const row = node
const title = displayTitle(node, t)
const selected = node.id === currentId
const status = sessionStatus(node, t)
const [menuOpen, setMenuOpen] = useState(false)
// Archive replaces the former Delete placeholder: it hides the row through
// the registry-global archive set and never touches the session log, so it
@@ -310,7 +326,14 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
drag.drop(rowHalf(e))
}}
>
<span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
<span className={css.slot}>
{status.state !== 'done' && (
<>
<StateDot state={status.state} />
<span className={css.visuallyHidden}>{status.label}</span>
</>
)}
</span>
<span className={css.title}>{title}</span>
{/* A blank New Session row is a provisional placeholder: nothing has
happened in it yet, so a "now" timestamp and the row verbs

View File

@@ -20,6 +20,8 @@ export interface SessionNode {
title: string
/** The provisional blank session (renderer shows the localized New Session title). */
blank: boolean
/** The runtime Session list reports a pending approval request for this Session. */
waitingApproval: boolean
running: boolean
updatedAt: number
}
@@ -169,6 +171,7 @@ function sessionNode(s: SessionSummary): SessionNode {
id: s.id,
title: sessionTitle(s),
blank: s.blank,
waitingApproval: s.waitingApproval,
running: s.running,
updatedAt: s.updatedAt,
}

View File

@@ -96,7 +96,7 @@ describe('workspace browser rows', () => {
it('renders and opens a selected running Session row', () => {
const node: SessionNode = {
id: sid('session'), title: 'Session', blank: false, running: true, updatedAt: 0,
id: sid('session'), title: 'Session', blank: false, waitingApproval: false, running: true, updatedAt: 0,
}
const onOpen = vi.fn()
render(
@@ -180,7 +180,7 @@ describe('workspace browser rows', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s-blank'), title: 'ignored', blank: true, running: false, updatedAt: 0,
id: sid('s-blank'), title: 'ignored', blank: true, waitingApproval: false, running: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -206,7 +206,7 @@ describe('workspace browser rows', () => {
const onFork = vi.fn()
const onArchive = vi.fn()
const node: SessionNode = {
id: sid('s1'), title: 'One', blank: false, running: false, updatedAt: 0,
id: sid('s1'), title: 'One', blank: false, waitingApproval: false, running: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
onRename={onRename} onFork={onFork} onArchive={onArchive} t={t} />)
@@ -234,11 +234,12 @@ describe('workspace browser rows', () => {
expect(screen.queryByRole('menu')).toBeNull()
})
it('shows the hover card after the dwell and suppresses it while the row menu is open', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s1'), title: 'Hovered', blank: false, running: true, updatedAt: 0,
id: sid('s1'), title: 'Hovered', blank: false, waitingApproval: false, running: true, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={undefined} now={60_000} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -248,7 +249,7 @@ describe('workspace browser rows', () => {
// Card body: full title + relative time + running status.
expect(screen.getAllByText('Hovered')).toHaveLength(2)
expect(screen.getByText('1分钟前')).toBeTruthy()
expect(screen.getByText('进行中')).toBeTruthy()
expect(screen.getAllByText('进行中')).toHaveLength(2)
fireEvent.pointerLeave(wrapper)
// Menu open (disabled=true) suppresses the card for the same hover.
fireEvent.click(screen.getByRole('button', { name: '会话“Hovered”的操作' }))
@@ -260,11 +261,38 @@ describe('workspace browser rows', () => {
}
})
it('shows approval waiting as warning ahead of the running state', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('approval'), title: 'Needs approval', blank: false,
waitingApproval: true, running: true, updatedAt: 0,
}
const view = render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
const row = screen.getByRole('treeitem')
expect(row.querySelector('[data-state="warning"]')).toBeTruthy()
expect(row.querySelector('[data-state="ongoing"]')).toBeNull()
expect(screen.getByText('等待审批')).toBeTruthy()
view.rerender(<SessionNodeItem node={{ ...node, running: false }} currentId={undefined} now={0}
onOpen={vi.fn()} onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
expect(screen.getByRole('treeitem').querySelector('[data-state="warning"]')).toBeTruthy()
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getAllByText('等待审批')).toHaveLength(2)
expect(document.querySelectorAll('[data-state="warning"]')).toHaveLength(2)
} finally {
vi.useRealTimers()
}
})
it('idle hover card shows the Idle status line', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s1'), title: 'Quiet', blank: false, running: false, updatedAt: 0,
id: sid('s1'), title: 'Quiet', blank: false, waitingApproval: false, running: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -279,7 +307,7 @@ describe('workspace browser rows', () => {
it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => {
const node: SessionNode = {
id: sid('s1'), title: 'Drag me', blank: false, running: false, updatedAt: 0,
id: sid('s1'), title: 'Drag me', blank: false, waitingApproval: false, running: false, updatedAt: 0,
}
const inactive = dragProps()
const { rerender } = render(

View File

@@ -38,6 +38,14 @@ describe('deriveGroups', () => {
expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')])
})
it('projects approval-waiting state into grouped and flat rows', () => {
const awaiting = { ...summary('awaiting', 10), waitingApproval: true, running: true }
const sessions = list(awaiting)
const grouped = deriveGroups(sessions, [workspace('project', ['awaiting'])], noArchive, view(['project']))
expect(grouped[0]!.sessions[0]).toMatchObject({ waitingApproval: true, running: true })
expect(deriveFlat(sessions, noArchive)[0]).toMatchObject({ waitingApproval: true, running: true })
})
it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => {
const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other'))
const groups = deriveGroups(sessions, [workspace('first', ['owned'])], noArchive, view([UNGROUPED_KEY]))

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: d4af267ef46f327bdabf31429b291ffd04308203
README.zh.md: f148cfa0c023b54d62016c5211205d391b634544
README.md: ed04a3431e4c9114b3115119c1e69d378cec77ae
README.zh.md: e50a7f49cb8753f5b26dd27cb1d48a9cc18e9fd7

View File

@@ -10,6 +10,8 @@ Wire messages form a four-quadrant discriminated union — who initiates × requ
The layering/protocol decisions are recorded in the [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md); the browser-side consumption architecture in the [web client architecture RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md).
Question responses are validated against their pending request before the first answer claims it. A multi-select item may carry both requested option labels in `selected` and non-empty `custom` text; a single-select item must use one or the other. Duplicate labels, unknown labels, mismatched ids, incomplete batches, and empty custom text are rejected as `bad-response`.
`session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message`, `assistant/message`, and `steering/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it.
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.

View File

@@ -10,6 +10,8 @@
分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中;浏览器侧消费架构记录在 [Web 客户端架构 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)中。
首个回答认领待处理请求之前,系统会对照该请求校验问题响应。多选题的回答项可以同时携带 `selected` 中的请求选项标签与非空 `custom` 文本单选题的回答项必须二选一。标签重复、标签未知、id 不匹配、批次不完整以及自定义文本为空都会以 `bad-response` 拒绝。
`session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent智能体然后按追加来源的消息边界分页。`maxMessages` 统计以追加方式进入 surface 的 `user/message``assistant/message``steering/message` 事件因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间从而让压缩compaction的仅日志溯源信息与引用它的替换留在同一页。
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections``@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元铸造一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema协议 schema 对 `values`/`value` 保持宽松loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。

View File

@@ -412,8 +412,10 @@ function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQues
if (new Set(answer.selected).size !== answer.selected.length) return false
const custom = answer.custom?.trim()
if (custom !== undefined && custom === '') return false
if (custom !== undefined && answer.selected.length > 0) return false
if (question.multiSelect !== true && answer.selected.length > 1) return false
if (question.multiSelect !== true) {
if (custom !== undefined && answer.selected.length > 0) return false
if (answer.selected.length > 1) return false
}
const labels = new Set(question.options?.map(option => option.label) ?? [])
return answer.selected.every(label => labels.has(label))
})

View File

@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { ApiProxy, MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '../src/api-proxy.ts'
async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(UserInteractionService)
return {
ctx,
api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }),
}
}
function agent(id: string): Agent {
return { id } as unknown as Agent
}
function openMux(api: ApiProxy, abort: AbortController): {
envelopes: RpcRequest<MuxFrame>[]
waitForQuestion(): Promise<RpcRequest<Extract<MuxFrame, { type: 'question/requested' }>>>
} {
const envelopes: RpcRequest<MuxFrame>[] = []
let resolveQuestion!: (value: RpcRequest<Extract<MuxFrame, { type: 'question/requested' }>>) => void
const question = new Promise<RpcRequest<Extract<MuxFrame, { type: 'question/requested' }>>>((resolve) => {
resolveQuestion = resolve
})
void (async () => {
for await (const envelope of api.events.mux({ rpcId: RpcId('question-mux'), payload: {} }, abort.signal)) {
envelopes.push(envelope)
if (envelope.payload.type === 'question/requested') {
resolveQuestion(envelope as RpcRequest<Extract<MuxFrame, { type: 'question/requested' }>>)
}
}
})()
return { envelopes, waitForQuestion: () => question }
}
function answer(
envelope: RpcRequest<Extract<MuxFrame, { type: 'question/requested' }>>,
selected: string[],
custom?: string,
): Parameters<ApiProxy['respond']>[0] {
return {
type: 'client-response',
rpcId: envelope.rpcId,
result: {
ok: true,
value: {
sessionId: envelope.payload.sessionId,
answer: {
answers: [{
id: envelope.payload.questions[0]?.id,
selected,
...custom === undefined ? {} : { custom },
}],
},
},
},
}
}
describe('question response validation', () => {
it('accepts selected options with custom text for multi-select questions', async () => {
const { ctx, api } = await harness()
const abort = new AbortController()
const mux = openMux(api, abort)
const asked = ctx.userInteraction.ask({
agent: agent('session-multi'),
questions: [{
id: 'targets',
question: 'Choose targets and add another',
multiSelect: true,
options: [{ label: 'Code' }, { label: 'Docs' }],
}],
})
const envelope = await mux.waitForQuestion()
expect(await api.respond(answer(envelope, ['Code', 'Docs'], 'Release notes')))
.toEqual({ accepted: true })
await expect(asked).resolves.toEqual({
answers: [{ id: 'targets', selected: ['Code', 'Docs'], custom: 'Release notes' }],
})
expect(mux.envelopes.some(item => item.payload.type === 'question/resolved')).toBe(true)
abort.abort()
})
it('keeps selected options and custom text mutually exclusive for single-select questions', async () => {
const { ctx, api } = await harness()
const abort = new AbortController()
const mux = openMux(api, abort)
const asked = ctx.userInteraction.ask({
agent: agent('session-single'),
questions: [{
id: 'target',
question: 'Choose one target',
options: [{ label: 'Code' }, { label: 'Docs' }],
}],
})
const envelope = await mux.waitForQuestion()
expect(await api.respond(answer(envelope, ['Code'], 'Release notes')))
.toEqual({ accepted: false, reason: 'bad-response' })
expect(await api.respond(answer(envelope, [], 'Release notes')))
.toEqual({ accepted: true })
await expect(asked).resolves.toEqual({
answers: [{ id: 'target', selected: [], custom: 'Release notes' }],
})
abort.abort()
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/tool-ask-user/README.md
README.md: 8e779f4025c20cd200344efb7cb8cd6bc09ba64d
README.zh.md: acaffec0764404a0e0e842ffc2b4efdee8869c4f
README.md: 64da4d75d01a0df0ae51b1557ed1c796317b906f
README.zh.md: 48a5b5d0d1aadff8a522d0b6100c6d0479b948a5

View File

@@ -15,7 +15,7 @@ Model-facing `ask_user_question` tool over `ctx.userInteraction`. It lets the mo
- `options` — optional choices with `label` and `description`. If recommending a choice, put it first and append `(Recommended)` to that label.
- `multi_select` — whether that question may return more than one selected option.
The tool calls `ctx.userInteraction.ask()` and returns canonical `{ answers: [{ id, selected, custom? }] }`. `selected` contains option labels; `custom` is present only for a free-form answer and overrides selected choices. The Native renderer preserves the compact JSON text shape `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`.
The tool calls `ctx.userInteraction.ask()` and returns canonical `{ answers: [{ id, selected, custom? }] }`. `selected` contains option labels; `custom` carries a free-form answer, supplementing `selected` for a multi-select question and overriding it for a single-select question. The Native renderer preserves the compact JSON text shape `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`.
## Role

View File

@@ -15,7 +15,7 @@
- `options`:可选选项,包含 `label``description`。如需推荐某个选项,请将其置于首位,并在该标签末尾追加 `(Recommended)`
- `multi_select`:该问题是否可以返回多个选中的选项。
工具调用 `ctx.userInteraction.ask()`,并返回规范的 `{ answers: [{ id, selected, custom? }] }``selected` 包含选项标签;仅当用户自由填写回答时才会出现 `custom`并覆盖选中的选项。Native 渲染器会保留紧凑的 JSON 文本形式 `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`
工具调用 `ctx.userInteraction.ask()`,并返回规范的 `{ answers: [{ id, selected, custom? }] }``selected` 包含选项标签;`custom` 携带自由填写回答,对于多选题会补充 `selected`对于单选题则会覆盖它。Native renderer 会保留紧凑的 JSON 文本形式 `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`
## 职责

View File

@@ -140,7 +140,8 @@ describe('ask_user_question tool', () => {
async ask() {
return {
answers: [
{ id: 'targets', selected: ['tests', 'docs'] },
{ id: 'targets', selected: ['tests', 'docs'], custom: 'release notes' },
{ id: 'labels-only', selected: ['tests'] },
{ id: 'notes', selected: [], custom: 'ship today' },
],
}
@@ -159,6 +160,12 @@ describe('ask_user_question tool', () => {
options: [{ label: 'tests' }, { label: 'docs' }],
multi_select: true,
},
{
id: 'labels-only',
question: 'Which labels should I keep?',
options: [{ label: 'tests' }, { label: 'docs' }],
multi_select: true,
},
{ id: 'notes', question: 'Any note?' },
],
},
@@ -168,13 +175,14 @@ describe('ask_user_question tool', () => {
if (result.isError) throw new Error('expected ask_user_question success')
expect(result.value).toEqual({
answers: [
{ id: 'targets', selected: ['tests', 'docs'] },
{ id: 'targets', selected: ['tests', 'docs'], custom: 'release notes' },
{ id: 'labels-only', selected: ['tests'] },
{ id: 'notes', selected: [], custom: 'ship today' },
],
})
expect(result.content).toEqual([{
type: 'text',
text: '{"answers":[{"id":"targets","selected":["tests","docs"]},{"id":"notes","selected":[],"custom":"ship today"}]}',
text: '{"answers":[{"id":"targets","selected":["tests","docs"],"custom":"release notes"},{"id":"labels-only","selected":["tests"]},{"id":"notes","selected":[],"custom":"ship today"}]}',
}])
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/tui/README.md
README.md: ac45a0ec9c282f3c872b325fe30a083dd1deed33
README.zh.md: 6bcb431713dd8a247d2b39d3392edc369ad00c90
README.md: 78d56a6cacd040fdd32b73f779bab3fb4c77fcce
README.zh.md: c403605bb13d252eec00a2b0ebafb5f953c884f8

View File

@@ -156,7 +156,7 @@ Append-only; newly visible content follows the reusable request prefix and does
#### What the model sees
When a consumer calls `ctx.userInteraction.ask()`, this provider presents each question in order and returns selected option labels or `custom` text. Abort, cancellation, or UI disposal becomes `Error: ask_user_question was interrupted before the user answered` through `dsh-tool-ask-user`.
When a consumer calls `ctx.userInteraction.ask()`, this provider presents each question in order and returns selected option labels, `custom` text, or both for a multi-select question. Pending custom text survives switching back to options and joins checked labels on a later options-mode submit. Abort, cancellation, or UI disposal becomes `Error: ask_user_question was interrupted before the user answered` through `dsh-tool-ask-user`.
#### Token effect

View File

@@ -156,7 +156,7 @@ Paths prefixed with @ are files explicitly referenced by the user. Use the read
#### 模型看到的内容
消费方调用 `ctx.userInteraction.ask()` 时,此提供方会按顺序显示各个问题,并返回选中选项标签`custom` 文本。中止、取消或 UI dispose 会变为 `Error: ask_user_question was interrupted before the user answered`;该转换由 `dsh-tool-ask-user` 完成。
消费方调用 `ctx.userInteraction.ask()` 时,此提供方会按顺序显示各个问题,并返回选中选项标签`custom` 文本,或为多选题同时返回两者。切回选项后,待提交的自定义文本仍会保留,并在之后从选项模式提交时与已勾选的标签一同返回。中止、取消或 UI dispose 会变为 `Error: ask_user_question was interrupted before the user answered`;该转换由 `dsh-tool-ask-user` 完成。
#### Token 影响

View File

@@ -868,12 +868,15 @@ export class QuestionDialog implements Component, Focusable {
if (this.selected.has(this.selectedIndex)) this.selected.delete(this.selectedIndex)
else this.selected.add(this.selectedIndex)
} else if (matchesKey(data, Key.enter)) {
const indices = this.question.multiSelect ? [...this.selected].sort((a, b) => a - b) : [this.selectedIndex]
if (indices.length === 0) {
const selected = this.question.multiSelect
? this.selectedOptionLabels()
: [options[this.selectedIndex]?.label].filter((label): label is string => label !== undefined)
const custom = this.question.multiSelect ? this.input.getValue().trim() : ''
if (selected.length === 0 && custom === '') {
this.error = 'Select at least one option, or press Tab for a custom answer.'
return
}
this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) })
this.done({ selected, ...(custom === '' ? {} : { custom }) })
} else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') {
this.mode = 'custom'
this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 }
@@ -889,7 +892,17 @@ export class QuestionDialog implements Component, Focusable {
this.error = 'Enter an answer before submitting.'
return
}
this.done({ selected: [], custom })
this.done({
selected: this.question.multiSelect ? this.selectedOptionLabels() : [],
custom,
})
}
private selectedOptionLabels(): string[] {
return [...this.selected]
.sort((a, b) => a - b)
.map(index => this.options[index]?.label)
.filter((label): label is string => label !== undefined)
}
/** Page backward through an oversized option, then through question detail. */
@@ -955,9 +968,12 @@ export class QuestionDialog implements Component, Focusable {
}
headerLines.push('')
const customHint = this.palette.dim(this.options.length > 0
? 'Enter submit • Esc options'
: 'Enter submit • Esc cancel')
const customControls = [
...(this.options.length > 0 && this.question.multiSelect ? [`${this.selected.size} selected`] : []),
'Enter submit',
this.options.length > 0 ? 'Esc options' : 'Esc cancel',
]
const customHint = this.palette.dim(customControls.join(' • '))
const footerLines: string[] = []
if (this.mode === 'custom') {
for (const line of this.input.render(innerWidth)) footerLines.push(line)

View File

@@ -5890,8 +5890,29 @@ describe('TUI user-interaction dialogs', () => {
result.terminal.send(' ')
result.terminal.send('\x1b[B')
result.terminal.send(' ')
result.terminal.send('\t')
await tick()
expect(result.terminal.output).toContain('2 selected • Enter submit • Esc options')
result.terminal.send('Tests')
result.terminal.send('\r')
await expect(multi).resolves.toEqual({ answers: [{ id: 'targets', selected: ['Code', 'Docs'] }] })
await expect(multi).resolves.toEqual({
answers: [{ id: 'targets', selected: ['Code', 'Docs'], custom: 'Tests' }],
})
const labelsOnly = result.ctx.userInteraction.ask({
questions: [{
id: 'labels-only',
question: 'Pick one target',
multiSelect: true,
options: [{ label: 'Code' }, { label: 'Docs' }],
}],
})
await tick()
result.terminal.send(' ')
result.terminal.send('\r')
await expect(labelsOnly).resolves.toEqual({
answers: [{ id: 'labels-only', selected: ['Code'] }],
})
const custom = result.ctx.userInteraction.ask({
questions: [{ id: 'other', question: 'Choose or type', options: [{ label: 'Default' }] }],
@@ -5934,7 +5955,6 @@ describe('TUI user-interaction dialogs', () => {
options: [{ label: 'One', description: 'first' }, { label: 'Two' }],
}],
})
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await tick()
result.terminal.send('\x1b[A')
result.terminal.send('\x1b[B')
@@ -5950,11 +5970,17 @@ describe('TUI user-interaction dialogs', () => {
})
result.terminal.send('c')
await tick()
result.terminal.send('keep this')
await tick()
expect(result.terminal.output).toContain('0 selected • Enter submit • Esc options')
result.terminal.send('\x1b')
await tick()
expect(result.terminal.output).toContain('Space toggle')
result.terminal.send('\x03')
await rejected
result.terminal.send(' ')
result.terminal.send('\r')
await expect(answer).resolves.toEqual({
answers: [{ id: 'options', selected: ['One'], custom: 'keep this' }],
})
await dispose(result)
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/user-interaction/README.md
README.md: d62e75d110b8be339c5f9449b0834320f695ac99
README.zh.md: 55258e85e56df2375ed8f195fa0b3b731a9cb816
README.md: c7fec590d6e44a13b94cc682f5e069b2d3c5e416
README.zh.md: 340af3541a09a528aa0fcc580bda070ea703f39e

View File

@@ -20,7 +20,7 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod
- `UserInteractionProvider` — UI implementation with `ask(request)`.
- `UserInteractionError``HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`.
When an answer includes `custom`, `selected` is empty; custom text is an override rather than a supplement to selected choices. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch.
For a single-select question, `custom` overrides the selected choice and `selected` is empty. For a multi-select question, `custom` may supplement the labels in `selected`. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch.
### Presentation intent

View File

@@ -20,7 +20,7 @@
- `UserInteractionProvider`:包含 `ask(request)` 的 UI 实现。
- `UserInteractionError``HarnessError` 的子类,包含 `EMPTY_QUESTIONS``BAD_INTENT``NO_PROVIDER``DUPLICATE_PROVIDER``ASK_ABORTED` 等代码。
当回答包含 `custom` 时,`selected` 为空;自定义文本是所选选项的替代,而不是补充。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。
对于单选题,`custom` 会覆盖选中的选项,且 `selected` 为空。对于多选题,`custom` 可以补充 `selected` 中的标签。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。
### 呈现意图

View File

@@ -53,7 +53,7 @@ export interface AskUserQuestionItem {
export interface AskUserQuestionAnswerItem {
/** The answered question id. */
id: string
/** Selected option labels. Empty for custom or unanswered choices. */
/** Selected option labels. May accompany custom text for a multi-select question. */
selected: string[]
/** Optional free-text "Other" answer. */
custom?: string

View File

@@ -23,6 +23,7 @@
"apps/web/tests/settings-chrome.e2e.ts",
"apps/web/tests/models-settings.e2e.ts",
"apps/web/tests/onboarding-deepseek-config.e2e.ts",
"apps/web/tests/remote-welcome.e2e.ts",
"apps/web/tests/workspace-management.e2e.ts",
"apps/web/tests/replay-round-trip.e2e.ts",
"apps/web/tests/hmr-live.e2e.ts",