refactor: apply repository naming contract

Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
Tianyi Cui
2026-08-13 00:36:22 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 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 packages/client/ui-user-questions/README.md
README.md: ba4b23150a7f3a2536db97df99410cb92238210e
README.zh.md: 96c81800ef85514c565defecc779da7d2e6523b7

View File

@@ -0,0 +1,26 @@
# @deepseek-ai/dsh-client-ui-user-questions
English | [中文](README.zh.md)
Web question feature plugin: its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot. Its host half is empty on purpose — mounting `dsh-tool-ask-user` there put the tool in the registry's GLOBAL layer, which merges into every agent regardless of the preset that composed it, so a two-tool benchmark preset really presented three. Rendering a question is a host UI capability; having the tool is an agent capability, so the `tool-ask-user` row belongs to the presets that want it (and to the TUI composition, which has no presets).
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.
Selection state is local to a component keyed by the request rpcId. A replay with the same id preserves a still-mounted draft, while `question/resolved` from the host removes the composer. The host remains authoritative: successful HTTP delivery does not remove pending state locally.
Composer chrome copy (pager, buttons, placeholders, validation feedback) is bilingual: the plugin registers zh/en dictionaries under the `question` namespace of `dsh-client-locale` and hands the entry its bound translator plus the locale snapshot source through the inject face, so a locale switch re-renders a mounted composer. Question and option text arrives from the model and renders verbatim; carrier failure messages also display untranslated.
## Model Experience
Indirectly, through `dsh-tool-ask-user`; that package owns the model-visible tool schema and structured result.
#### KV Cache effect
No direct invalidation; `dsh-tool-ask-user` owns the model-visible tool call and result.
## Known Limitations and Deferred Work
- **Unsubmitted drafts are not durable** — reconnect resync or a full page reload restores the host-owned pending request with the same rpcId, but a composer unmount resets local option and custom-text drafts.
- **One request owns the composer at a time** — later pending requests remain in the session snapshot and become visible after the earlier request resolves.

View File

@@ -0,0 +1,26 @@
# @deepseek-ai/dsh-client-ui-user-questions
[English](README.md) | 中文
Web 提问功能插件:其浏览器侧把 `question` 条目注册到会话拥有的 `conversation.composer` 键控 slot 中。其主机侧刻意为空——在那里挂载 `dsh-tool-ask-user` 会把工具放进注册表的**全局层**,而全局层会并入每一个 agent(智能体),无论它由哪个 preset 组装,于是一个「两工具」的 benchmark preset 实际会呈现三个。渲染提问是宿主的 UI 能力,拥有该工具则是 agent 的能力,因此 `tool-ask-user` 行属于需要它的各个 preset(以及没有 preset 的 TUI 组装)。
组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `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` 存在、提供了被指名的批准标签,且是二元单选(除批准外最多一个选项,且非多选)。其他任何情形——没有意图、一批含多个问题、缺少计划、批准标签未命中任何选项、出现第三个选项、多选决定——都留在能够表达它的通用流程上。意图改变的只是布局,从不改变可达的答案。
选择状态只存在于以请求 rpcId 为 key 的组件本地。使用相同 id 回放时,只要组件仍挂载,就会保留草稿;主机发出的 `question/resolved` 则会移除编辑器。主机仍具有最终决定权:HTTP 交付成功不会在本地移除待处理状态。
编辑器外框文案(翻页器、按钮、占位符、校验提示)是双语的:插件在 `dsh-client-locale` 的 `question` 命名空间下注册 zh/en 词典,并通过 inject face 把绑定的翻译函数和 locale 快照源交给该条目,因此切换语言会重新渲染已挂载的编辑器。问题与选项文本来自模型并原样渲染;载体失败消息也不经翻译直接显示。
## 模型体验
通过 `dsh-tool-ask-user` 间接影响;该包拥有模型可见的工具 schema 和结构化结果。
#### KV Cache 影响
不会直接失效;模型可见的工具调用与结果由 `dsh-tool-ask-user` 拥有。
## 已知限制与暂缓事项
- **未提交的草稿不持久**:重新连接再同步或完整刷新页面时,会恢复主机拥有且 rpcId 相同的待处理请求,但编辑器卸载会重置本地选项和自定义文本草稿。
- **每次只有一个请求拥有编辑器**:后续待处理请求仍留在会话快照中,并在较早请求落定后显示。

View File

@@ -0,0 +1,78 @@
{
"name": "@deepseek-ai/dsh-client-ui-user-questions",
"description": "Web ask_user_question feature: host tool mount plus composer-takeover question UI",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/client/ui-user-questions"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"clsx": "^2.0.0",
"react": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-questions": "workspace:^",
"@types/react": "~18.3.1"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts"
]
}

View File

@@ -0,0 +1,123 @@
/* Plan-review takeover: the waiting-approval card language (amber strip on a
floating capsule, right-aligned actions) applied to a reviewed plan. Kept as
its own module rather than shared with ui-conversation's ApprovalPanel: the
two takeovers agree on tokens and geometry, not on content — this one's body
is scrollable markdown, that one's is a headline plus a command line. Warn
semantics ride the alias state tokens; no hardcoded colors. */
/* Mirrors the question card's frame so the takeover is a content swap. */
.frame {
display: flex;
justify-content: center;
/* Sides = clearance + 16px: the card lands on the shared content width
(input card - 32) at every viewport. */
padding: 6px calc(var(--dsh-composer-side-clearance) + 16px) 10px;
}
.card {
display: flex;
overflow: hidden;
flex-direction: column;
width: 100%;
max-width: var(--dsh-chat-content-width);
/* Composer seat sits in a fixed-height conversation column (overflow
hidden): cap the card against the viewport and scroll the plan, so the
strip and the decision row stay reachable on a long plan. */
max-height: min(60vh, 520px);
border: 1px solid var(--dsw-alias-state-warn-secondary);
border-radius: 20px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv2);
color: var(--dsw-alias-label-primary);
/* Elevated surface in dark: the plan body inside scrolls once the card hits
the cap above, so the thumb takes the l2 pair (see ui-theme
styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.card,
.card * {
box-sizing: border-box;
}
/* Tinted full-width header band, as on the approval takeover. */
.strip {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 8px;
padding: 10px 16px;
background: var(--dsw-alias-state-warn-tertiary);
color: var(--dsw-alias-state-warn-primary);
font-size: 13px;
line-height: 18px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--dsw-alias-state-warn-primary);
}
/* The plan is the panel's message: it takes the whole body and the scroll. */
.body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: 12px 16px 4px;
font-size: 14px;
line-height: 22px;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
gap: 12px;
padding: 8px 16px 12px;
}
.feedback {
min-height: 16px;
color: var(--dsw-alias-state-error-primary);
font-size: 11px;
line-height: 16px;
}
.actions {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 8px;
}
/* The discuss verb stays a quiet text button beside the two decision
capsules: 14px glyph against the 14px label with a slightly wider gap, so
the icon reads as a prefix rather than a peer-sized control. */
.discuss {
gap: 6px;
color: var(--dsw-alias-label-secondary);
}
.discuss:hover:not(:disabled) {
color: var(--dsw-alias-label-primary);
}
@media (max-width: 720px) {
.card {
border-radius: 16px;
}
.body {
padding: 10px 12px 4px;
}
.footer {
align-items: flex-end;
padding: 8px 12px 10px;
}
}

View File

@@ -0,0 +1,100 @@
// PlanReviewPanel: the composer takeover for a question carrying the
// `plan-review` presentation intent. A plan under review is one decision over
// one body of markdown, so it takes the waiting-approval card shape — tinted
// strip, content, right-aligned action row — instead of the generic question
// flow's pager, numbered options, skip and custom-answer affordances, which
// read as a quiz the user is being graded on.
//
// The three actions are the whole decision surface: approve and decline answer
// the question with the option labels the asker offered (localised copy on the
// buttons, the asker's descriptions as their tooltips), while "discuss"
// dismisses the request so the composer returns and the user can simply say
// what they want. Dismissal is the generic flow's own cancel verb, promoted to
// a labelled button because in a two-outcome decision it is the third real
// answer, not an escape hatch.
import { useState } from 'react'
import { Button, IconEditOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PendingQuestion, PlanReview, QuestionComposerProps } from './contract/slots.ts'
import css from './PlanReviewPanel.module.css'
/** The panel's own props: the question domain face, the narrowed review, and the locale seat. */
export type PlanReviewPanelProps =
{ pending: PendingQuestion; review: PlanReview } & Pick<QuestionComposerProps, 't'>
/**
* Optional-prop spread for a decision button's tooltip: `title` is optional on
* the DOM props, and exactOptionalPropertyTypes rejects an explicit undefined.
*
* @param description - the asker's option description, when it carries one.
* @returns The `title` prop to spread, or nothing.
*/
function tooltip(description: string | undefined): { title?: string } {
return description === undefined ? {} : { title: description }
}
/**
* Render a plan review as a decision card.
*
* @param props - the question domain face, the narrowed plan review, and `t`.
* @returns The plan-review takeover for this request.
*/
export function PlanReviewPanel({ pending, review, t }: PlanReviewPanelProps) {
// One-shot latch shaped like the approval takeover's: the panel leaves only
// when the host's resolved frame lands, so until then a second click must
// not re-fire. A failed send (rejected receipt / transport) re-arms it and
// shows why, since nothing else would tell the user the click was lost.
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const settle = (send: () => Promise<void>): void => {
setBusy(true)
setError(null)
void send().catch((cause: unknown) => {
setBusy(false)
setError(cause instanceof Error ? cause.message : String(cause))
})
}
const decide = (label: string): void => {
settle(() => pending.answer({ answers: [{ id: review.id, selected: [label] }] }))
}
const decline = review.decline
return (
<div className={css.frame} data-plan-review-key={pending.key}>
<section className={css.card} aria-label={review.question}>
<div className={css.strip}>
<span className={css.dot} />
{t('plan.header')}
</div>
<div className={css.body} data-plan-review-scroll>
<MarkdownText text={review.plan} />
</div>
<div className={css.footer}>
<div className={css.feedback} role="status">{error}</div>
<div className={css.actions}>
<Button
variant="ghost" className={css.discuss} icon={<IconEditOutline16 size={14} />}
disabled={busy} onClick={() => { settle(() => pending.cancel()) }}
>
{t('plan.discuss')}
</Button>
{decline !== undefined && (
<Button
variant="outline" {...tooltip(decline.description)}
disabled={busy} onClick={() => { decide(decline.label) }}
>
{t('plan.decline')}
</Button>
)}
<Button
variant="primary" {...tooltip(review.approve.description)}
disabled={busy} onClick={() => { decide(review.approve.label) }}
>
{t('plan.approve')}
</Button>
</div>
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,407 @@
/* The takeover seats where the input card sits, centered on the InputBar
axis at the shared content width (input card - 32): sides = clearance +
16px so the relation also holds on narrow viewports. */
.frame {
display: flex;
justify-content: center;
padding: 6px calc(var(--dsh-composer-side-clearance) + 16px) 10px;
}
/* Figma Input 973:36348 body over the 1019:36938 header: no banner strip —
the card keeps zero padding and sections carry their own insets. */
.card {
display: flex;
flex-direction: column;
width: 100%;
max-width: var(--dsh-chat-content-width);
/* Composer seat sits in a fixed-height conversation column (overflow
hidden): cap the card against the viewport and scroll the option list
so header and footer actions stay reachable on long batches. */
max-height: min(60vh, 520px);
padding: 0 0 10px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 20px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv2);
color: var(--dsw-alias-label-primary);
overflow: hidden;
/* Elevated surface in dark, same as the menus: the body inside scrolls
once the card hits the cap above, so the thumb takes the l2 pair. Declared
on the card because the elevation belongs to the surface, and the custom
properties inherit down to `.body` (see ui-theme styles/scrollbar.css
for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.card,
.card * {
box-sizing: border-box;
}
/* Figma 1019:36938 header, user-tuned: heading block left, close right; the
pager sits in the footer to balance the card. */
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-shrink: 0;
padding: 20px 16px 0 24px;
}
.headingBlock {
min-width: 0;
}
.eyebrow {
/* Eyebrow-to-title gap widened from the figma 2px (user-tuned). */
margin-bottom: 5px;
color: var(--dsw-alias-label-tertiary);
font-size: 11px;
line-height: 16px;
}
.title {
margin: 0;
font-size: 16px;
line-height: 22px;
font-weight: 500;
}
.detail {
margin: 0 2px 8px;
}
.footerActions {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.pager {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.progress {
padding: 0 4px;
color: var(--dsw-alias-label-secondary);
font-size: 14px;
line-height: 24px;
font-weight: 500;
white-space: nowrap;
/* Narrow the plain spaces around the slash without touching glyph tracking. */
word-spacing: -2px;
}
.iconButton {
display: grid;
place-items: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: 999px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.iconButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.iconButton:disabled {
color: var(--dsw-alias-label-dimmed);
cursor: default;
}
.body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
.options {
display: flex;
flex-direction: column;
gap: 1px;
margin: 8px 0 0;
padding: 4px 12px;
}
.option {
display: flex;
/* flex-start, not center: with a wrapped description the indicator must
stay on the FIRST line (centering drifts it down the taller copy block).
The 8px padding makes a single-line row 40px exactly, so nothing reads
as top-heavy; .number/.checkbox re-center against the first line box. */
align-items: flex-start;
gap: 8px;
width: 100%;
min-height: 40px;
/* Rows are the scroll content, never the slack absorber: a shrinkable row
collapses to min-height while its wrapped copy keeps the taller
intrinsic height, and centered content then paints outside the row box —
over the title and the next row. Overflow belongs to .options. */
flex-shrink: 0;
padding: 8px 12px 8px 8px;
border: 1px solid transparent;
border-radius: 12px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
transition: background-color 120ms ease, border-color 120ms ease;
}
.option:hover:not(:disabled),
.optionSelected {
background: var(--dsw-alias-interactive-bg-hover);
}
.optionSelected {
border-color: var(--dsw-alias-border-l2);
}
.option:disabled {
cursor: default;
}
/* Leading indicator (figma 20×20, radius 6): single-select shows the option
number, multi-select swaps in a checkbox; the custom-answer row follows —
its checkbox mirrors the typed draft (styling only, exclusivity holds). */
.number {
display: grid;
place-items: center;
flex: 0 0 20px;
width: 20px;
height: 20px;
/* (24px first-line box − 20px indicator) / 2: centers the indicator against
the first text line under the row's flex-start alignment. */
margin-top: 2px;
border-radius: 6px;
background: var(--dsw-alias-bg-overlay);
color: var(--dsw-alias-label-secondary);
font-size: 12px;
font-weight: 500;
line-height: 18px;
}
/* Multi-select box (figma 1055:41594, user-tuned down to 14×14): a radius-4
box centered in the 20px indicator seat; the box itself is the ::before
layer so the check icon stacks over it in the same grid cell. */
.checkbox {
display: grid;
place-items: center;
flex: 0 0 20px;
width: 20px;
height: 20px;
/* Same first-line centering as .number under flex-start alignment. */
margin-top: 2px;
}
.checkbox::before {
content: '';
grid-area: 1 / 1;
width: 14px;
height: 14px;
border: 1px solid var(--dsw-alias-border-l4);
border-radius: 4px;
transition: background-color 120ms ease, border-color 120ms ease;
}
.checkbox > svg {
grid-area: 1 / 1;
}
/* Checked: label-primary fill with a primary-foreground check — the pair
inverts with the theme (dark fill in light mode, light fill in dark mode). */
.checkboxChecked {
color: var(--dsw-alias-label-primary-foreground);
}
.checkboxChecked::before {
border-color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-label-primary);
}
.optionCopy {
min-width: 0;
flex: 1;
}
.optionLine {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 2px 6px;
}
.optionLabel {
font-size: 14px;
line-height: 24px;
font-weight: 500;
}
.badge {
padding: 0 4px;
border-radius: 6px;
background: var(--dsw-specific-sidebar-nav-item-active-accent);
color: var(--dsw-alias-button-info-fill);
font-size: 11px;
line-height: 18px;
font-weight: 600;
}
.description {
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 24px;
font-weight: 400;
}
/* Custom answer row (figma 973:36427): an option-shaped row whose copy is an
inline text input; focus or a typed draft lifts it to the selected look. */
.customRow {
display: flex;
/* Same first-line alignment as .option — the indicator seat carries the
2px re-centering margin. */
align-items: flex-start;
gap: 8px;
width: 100%;
min-height: 40px;
/* Same reason as .option: the custom row is scroll content, and shrinking
it pushes the inline input past the footer. */
flex-shrink: 0;
padding: 8px 12px 8px 8px;
border: 1px solid transparent;
border-radius: 12px;
transition: background-color 120ms ease, border-color 120ms ease;
}
.customRow:hover,
.customRow:focus-within,
.customRowActive {
background: var(--dsw-alias-interactive-bg-hover);
}
.customRow:focus-within,
.customRowActive {
border-color: var(--dsw-alias-border-l2);
}
.customInput {
flex: 1;
min-width: 0;
padding: 0;
border: none;
outline: none;
background: transparent;
color: var(--dsw-alias-label-primary);
caret-color: var(--dsw-alias-state-business-primary);
font: inherit;
font-size: 14px;
line-height: 24px;
}
.customInput::placeholder {
color: var(--dsw-alias-label-caption);
}
/* Optionless question: the free-form answer is the whole body. The 12px side
margins add to the .options 12px padding so both edges align with the
title's 24px inset; type matches the option rows, no resize handle. */
.customTextarea {
display: block;
min-height: 64px;
max-height: 140px;
flex-shrink: 0;
margin: 0 12px;
padding: 8px 12px;
resize: none;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 10px;
outline: none;
background: var(--dsw-alias-bg-module-platform);
color: var(--dsw-alias-label-primary);
caret-color: var(--dsw-alias-state-business-primary);
font: inherit;
font-size: 14px;
line-height: 24px;
}
.customTextarea:focus {
border-color: var(--dsw-alias-state-business-primary);
}
.customTextarea::placeholder {
color: var(--dsw-alias-label-caption);
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
margin-top: 12px;
padding: 0 10px 0 18px;
}
.feedback {
flex: 1;
min-height: 16px;
color: var(--dsw-alias-state-error-primary);
font-size: 11px;
line-height: 16px;
text-align: right;
}
@media (max-width: 720px) {
.card {
border-radius: 16px;
}
.header {
padding: 10px 12px 0 18px;
}
.options {
padding: 4px 8px;
}
.title {
font-size: 15px;
line-height: 21px;
}
.option,
.customRow {
padding: 8px 6px;
}
.footer {
align-items: flex-end;
padding: 0 10px;
}
.footerActions {
flex-shrink: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.option,
.customRow {
transition: none;
}
}

View File

@@ -0,0 +1,337 @@
import { useMemo, useState, type ChangeEvent, type KeyboardEvent } from 'react'
import clsx from 'clsx'
import {
Button, IconCheckOutline14, IconChevronLeftOutline14, IconChevronRightOutline14,
IconCloseOutline16, IconEditOutline16, MarkdownText,
} from '@deepseek-ai/dsh-client-ui-primitives'
import {
PendingQuestion, planReviewOf,
type QuestionAnswer, type QuestionComposerProps,
} from './contract/slots.ts'
import { PlanReviewPanel } from './PlanReviewPanel.tsx'
import css from './QuestionComposer.module.css'
interface DraftAnswer {
selected: string[]
custom: string
skipped: boolean
}
/**
* Displayed feedback: validation feedback is stored as a dictionary KEY and
* translated at render, so already-shown feedback follows a locale switch;
* runtime failure messages (finished strings from the wire) pass through
* verbatim.
*/
type Feedback = { key: 'error.incomplete' | 'error.unanswered' } | { text: string }
/**
* Split the conventional recommendation suffix without changing the answer value.
* @param label - Original option label returned if selected.
* @returns Display label plus recommendation state.
*/
export function parseRecommendedLabel(label: string): { label: string; recommended: boolean } {
const suffix = /\s*(?:\((?:recommended|推荐)\)|((?:recommended|推荐)))\s*$/i
return suffix.test(label)
? { label: label.replace(suffix, ''), recommended: true }
: { label, recommended: false }
}
/** Return whether a text-field key event belongs to an active IME composition. */
function isComposing(event: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>): boolean {
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
// oxlint-disable-next-line typescript/no-deprecated
return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229
}
/**
* Composer takeover boundary; the carrier key keys local drafts, so a
* same-request replay (same key, new carrier object) preserves them.
*
* One takeover, two shapes: a request that declares a presentation intent this
* package renders takes that shape (a plan review is one decision over one
* plan, not a question set), and every other request takes the generic flow.
* The routing lives here, at the one entry that owns the composer seat, so
* neither shape can claim a request the other is already rendering.
*
* @param props - the selector-matched pending question carrier plus the framework standard kit.
* @returns The question flow, or the intent's own surface, for this request.
*/
export function QuestionComposer(props: QuestionComposerProps) {
// Domain-face mint rides the carrier's stable identity (never minted in a
// select/render dispatch — per-dispatch minting would churn memo identity).
const question = useMemo(() => new PendingQuestion(props.matched), [props.matched])
const review = useMemo(() => planReviewOf(question.questions), [question])
return review === undefined
? <QuestionFlow key={question.key} pending={question} t={props.t} />
: <PlanReviewPanel key={question.key} pending={question} review={review} t={props.t} />
}
function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<QuestionComposerProps, 't'>) {
const questions = pending.questions
const [index, setIndex] = useState(0)
const [drafts, setDrafts] = useState<DraftAnswer[]>(() => questions.map(() => ({
selected: [], custom: '', skipped: false,
})))
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
const [error, setError] = useState<Feedback | null>(null)
// index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1.
// oxlint-disable-next-line typescript/no-non-null-assertion
const question = questions[index]!
// oxlint-disable-next-line typescript/no-non-null-assertion
const draft = drafts[index]!
const hasOptions = (question.options?.length ?? 0) > 0
const cancelFlow = (): void => {
setBusy('cancel')
setError(null)
void pending.cancel().catch((cause: unknown) => {
setBusy(null)
setError({ text: cause instanceof Error ? cause.message : String(cause) })
})
}
const updateDraft = (update: (current: DraftAnswer) => DraftAnswer): void => {
setDrafts(current => current.map((item, itemIndex) => itemIndex === index ? update(item) : item))
setError(null)
}
const choose = (label: string): void => {
updateDraft((current) => {
if (question.multiSelect === true) {
const selected = current.selected.includes(label)
? current.selected.filter(item => item !== label)
: [...current.selected, label]
return { ...current, selected, skipped: false }
}
return { selected: [label], custom: '', skipped: false }
})
if (question.multiSelect !== true && index < questions.length - 1) {
setIndex(current => current + 1)
}
}
const answered = (item: DraftAnswer): boolean =>
item.selected.length > 0 || item.custom.trim() !== ''
const completed = (item: DraftAnswer): boolean => answered(item) || item.skipped
const submitDrafts = (values: DraftAnswer[]): void => {
const missing = values.findIndex(item => !completed(item))
if (missing >= 0) {
setIndex(missing)
setError({ key: 'error.incomplete' })
return
}
const answer: QuestionAnswer = {
answers: questions.map((item, itemIndex) => {
const value = values[itemIndex] as DraftAnswer
if (value.skipped) return { id: item.id, selected: [] }
const custom = value.custom.trim()
return {
id: item.id,
selected: custom === '' || item.multiSelect === true ? value.selected : [],
...(custom === '' ? {} : { custom }),
}
}),
}
setBusy('answer')
setError(null)
void pending.answer(answer).catch((cause: unknown) => {
setBusy(null)
setError({ text: cause instanceof Error ? cause.message : String(cause) })
})
}
const continueFlow = (): void => {
if (!answered(draft)) {
setError({ key: 'error.unanswered' })
return
}
if (index < questions.length - 1) {
setIndex(current => current + 1)
setError(null)
return
}
submitDrafts(drafts)
}
// 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: question.multiSelect === true ? current.selected : [],
custom: value,
skipped: false,
}))
}
const continueFromCustom = (event: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
if (event.key !== 'Enter' || event.shiftKey || isComposing(event)) return
event.preventDefault()
continueFlow()
}
const skipQuestion = (): void => {
const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index
? { selected: [], custom: '', skipped: true }
: item)
setDrafts(nextDrafts)
setError(null)
if (index < questions.length - 1) {
setIndex(current => current + 1)
return
}
submitDrafts(nextDrafts)
}
return (
<div className={css.frame} data-question-key={pending.key}>
<section className={css.card} aria-labelledby={`question-${pending.key}-${String(index)}`}>
<header className={css.header}>
<div className={css.headingBlock}>
{question.header !== undefined && <div className={css.eyebrow}>{question.header}</div>}
<h2 className={css.title} id={`question-${pending.key}-${String(index)}`}>
{question.question}
</h2>
</div>
<button
type="button" className={css.iconButton} aria-label={t('nav.cancel')}
title={t('nav.cancel')}
disabled={busy !== null} onClick={cancelFlow}
>
<IconCloseOutline16 />
</button>
</header>
<div className={css.body} data-question-scroll>
{question.detail !== undefined && (
<div className={css.detail}><MarkdownText text={question.detail} /></div>
)}
<div className={css.options} role={question.multiSelect === true ? 'group' : 'radiogroup'}>
{(question.options ?? []).map((option, optionIndex) => {
const selected = draft.selected.includes(option.label)
const display = parseRecommendedLabel(option.label)
return (
<button
type="button" key={`${option.label}-${String(optionIndex)}`}
className={clsx(css.option, selected && question.multiSelect !== true && css.optionSelected)}
role={question.multiSelect === true ? 'checkbox' : 'radio'}
aria-checked={selected}
aria-label={display.label}
disabled={busy !== null}
onClick={() => { choose(option.label) }}
onKeyDown={(event) => {
if (event.key !== 'Enter' || !drafts.every(completed)) return
event.preventDefault()
submitDrafts(drafts)
}}
>
{question.multiSelect === true
? (
<span className={clsx(css.checkbox, selected && css.checkboxChecked)} aria-hidden="true">
{selected && <IconCheckOutline14 size={12} />}
</span>
)
: <span className={css.number}>{optionIndex + 1}</span>}
<span className={css.optionCopy}>
<span className={css.optionLine}>
<span className={css.optionLabel}>{display.label}</span>
{display.recommended && (
<span className={css.badge}>{t('option.recommended')}</span>
)}
{option.description !== undefined && (
<span className={css.description}>{option.description}</span>
)}
</span>
</span>
</button>
)
})}
{hasOptions
? (
<div className={clsx(css.customRow, draft.custom !== '' && css.customRowActive)}>
{question.multiSelect === true
? (
<span
className={clsx(css.checkbox, draft.custom !== '' && css.checkboxChecked)}
aria-hidden="true"
>
{draft.custom !== '' && <IconCheckOutline14 size={12} />}
</span>
)
: (
<span className={css.number} aria-hidden="true">
<IconEditOutline16 size={12} />
</span>
)}
<input
type="text"
className={css.customInput}
value={draft.custom}
disabled={busy !== null}
placeholder={t('custom.placeholder')}
onChange={draftCustom}
onKeyDown={continueFromCustom}
/>
</div>
)
: (
<textarea
autoFocus
className={css.customTextarea}
value={draft.custom}
disabled={busy !== null}
rows={2}
placeholder={t('custom.placeholder')}
onChange={draftCustom}
onKeyDown={continueFromCustom}
/>
)}
</div>
</div>
<footer className={css.footer}>
<div className={css.pager}>
<button
type="button" className={css.iconButton} aria-label={t('nav.prev')}
disabled={index === 0 || busy !== null}
onClick={() => { setIndex(index - 1); setError(null) }}
>
<IconChevronLeftOutline14 />
</button>
<span className={css.progress}>{index + 1} / {questions.length}</span>
<button
type="button" className={css.iconButton} aria-label={t('nav.next')}
disabled={index === questions.length - 1 || busy !== null}
onClick={() => { setIndex(index + 1); setError(null) }}
>
<IconChevronRightOutline14 />
</button>
</div>
<div className={css.feedback} role="status">
{error === null ? null : 'key' in error ? t(error.key) : error.text}
</div>
<div className={css.footerActions}>
<Button variant="outline" disabled={busy !== null} onClick={skipQuestion}>
{t('action.skip')}
</Button>
<Button
variant="primary"
disabled={busy !== null || !answered(draft)} onClick={continueFlow}
>
{busy === 'answer'
? t('submitting')
: index === questions.length - 1 ? t('submit') : t('action.next')}
</Button>
</div>
</footer>
</section>
</div>
)
}

View File

@@ -0,0 +1,143 @@
/**
* Question-composer slot contract: the registrant-side props composition for
* the conversation-owned `conversation.composer` slot, plus the question
* domain face over the runtime's carrier object. The carrier (PendingWait)
* owns envelope transport only; the question protocol — answer value shape,
* cancelled error encoding, receipt checks — lives HERE, with the package
* that consumes it.
*/
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Also pulls ui-conversation's SlotMap merge (the 'conversation.composer'
// entry) into every program that sees this contract, so PropsRuntime resolves.
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import type { QuestionResponsePayload } from '@deepseek-ai/dsh-api-remotes/client'
/** The pending question carrier the owner dispatches into the composer slot. */
export type QuestionWait = PendingWait<'question'>
/** One structured answer batch covering every question of the request. */
export type QuestionAnswer = QuestionResponsePayload['answer']
/** One question of the request, as the carrier payload carries it. */
type QuestionItem = QuestionWait['payload']['questions'][number]
/** One option the asker offered on a question. */
type QuestionOption = NonNullable<QuestionItem['options']>[number]
/**
* A request narrowed to the `plan-review` presentation intent: everything the
* decision card renders and answers with, so the panel never re-reads the
* request shape. `approve` and `decline` are the asker's own options — an
* answer must carry one of those labels verbatim — and `plan` is the markdown
* body under review.
*/
export interface PlanReview {
/** The reviewed question's id, echoed in the answer. */
id: string
/** The question text, kept as the card's accessible name. */
question: string
/** The plan markdown under review. */
plan: string
/** The option that approves the plan. */
approve: QuestionOption
/** The option that declines it; absent when the asker offered no other option. */
decline?: QuestionOption
}
/**
* Narrow a request to a renderable plan review, or return undefined to leave it
* to the generic question flow.
*
* The card is one decision over one plan, and it claims a request only when it
* can send every answer that request allows — an intent changes the layout,
* never which answers are reachable. So the batch must be a single question
* that declares the intent, carries the plan as its detail, offers the approve
* label the intent names, and is a binary single choice: at most one option
* besides approve, and not multi-select. A third option or a multi-select batch
* has answers two buttons cannot express, so the generic flow keeps it — as it
* keeps any request whose intent the asker's own service would have rejected,
* because the client sits downstream of a wire boundary and every request must
* stay answerable.
*
* @param questions - the request's whole question batch.
* @returns The narrowed review, or undefined when the generic flow owns it.
*/
export function planReviewOf(questions: readonly QuestionItem[]): PlanReview | undefined {
if (questions.length !== 1) return undefined
// Length-checked above; the index read is the narrowing tax, not a guess.
const question = questions[0] as QuestionItem
const intent = question.intent
if (intent?.kind !== 'plan-review' || question.detail === undefined) return undefined
if (question.multiSelect === true) return undefined
const options = question.options ?? []
if (options.length > 2) return undefined
const approve = options.find(option => option.label === intent.approve)
if (approve === undefined) return undefined
const decline = options.find(option => option.label !== intent.approve)
return {
id: question.id,
question: question.question,
plan: question.detail,
approve,
...(decline === undefined ? {} : { decline }),
}
}
/**
* Question domain face over the carrier: render identity and questions
* transparently forwarded; answer/cancel own the wire encoding (the success
* fields and the cancelled error) and turn a rejected carrier receipt into a
* thrown error. Components mint one per carrier via useMemo (never inside a
* select — a per-dispatch mint would churn identity and break memoization).
*/
export class PendingQuestion {
/**
* @param wait - the runtime carrier for one pending question request.
*/
constructor(private readonly wait: QuestionWait) {}
/** Opaque render identity (React key / draft remount axis), forwarded from the carrier. */
get key(): string {
return this.wait.key
}
/** The request's question list, forwarded from the carrier payload. */
get questions(): QuestionWait['payload']['questions'] {
return this.wait.payload.questions
}
/**
* Deliver the whole answer batch; a rejected carrier receipt throws.
* @param answer - complete structured answer batch.
*/
async answer(answer: QuestionAnswer): Promise<void> {
const receipt = await this.wait.respond({
ok: true, value: { sessionId: this.wait.sessionId, answer },
})
if (!receipt.accepted) {
throw new Error(`question response rejected: ${receipt.reason}`)
}
}
/** Reject the whole wait (the host resolves the tool call as cancelled); a rejected receipt throws. */
async cancel(): Promise<void> {
const receipt = await this.wait.respond({
ok: false,
error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
})
if (!receipt.accepted) {
throw new Error(`question cancellation rejected: ${receipt.reason}`)
}
}
}
/**
* Full component props: the framework runtime share (chain currency +
* session/global standard kit) plus the chain `matched` share — the entry's
* selector result, already narrowed to the question carrier — plus the
* standard locale seat; the carrier plus the domain face above carry the
* whole behavior surface.
*/
export type QuestionComposerProps =
PropsRuntime<'conversation.composer'> & { matched: QuestionWait } & PropsLocale<'question'>

View File

@@ -0,0 +1,60 @@
/**
* Web question plugin, browser half: QuestionComposer registered as a
* selector-routed entry of the conversation-declared composer chain, plus the
* `question` dictionaries. The selector narrows the owner's currency to the
* question carrier (matched prop), and the whole behavior surface rides the
* carrier (domain encoding in contract/slots.ts PendingQuestion); copy rides
* the standard locale seat. Export discipline: packages/client/AGENTS.md.
*
* One entry, two shapes: the composer renders a request that declares a
* presentation intent as that intent's own surface (`plan-review` → the plan
* decision card) and every other request as the generic question flow. A
* separate chain entry per shape would race the same carrier, so the shape
* choice lives inside this entry — see QuestionComposer.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { QuestionWait } from './contract/slots.ts'
import { QuestionComposer } from './QuestionComposer.tsx'
import { en, zh, type QuestionKey } from './locales.ts'
export { PendingQuestion } from './contract/slots.ts'
export type {
PlanReview, QuestionAnswer, QuestionComposerProps, QuestionWait,
} from './contract/slots.ts'
export type { QuestionKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The question composer's copy. */
question: QuestionKey
}
}
/** Dictionary namespace owned by this plugin. */
const NS = 'question'
/** Required services: the slot registry and the question composer's copy. */
export const inject = ['slots', 'locale']
/** Chain routing: claim the composer while a question wait is pending (pure — owner props only). */
function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | null {
return interactions.find((i): i is QuestionWait => i.kind === 'question') ?? null
}
/**
* Client plugin body: register the `question` dictionaries and the question
* composer into the composer chain. Zero business face — data and verbs live
* on the matched carrier; t rides the standard locale seat.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-user-questions: dictionaries')
ctx.slots.inject('conversation.composer', () => ctx.slots.register(
{ name: 'conversation.composer', select: selectQuestion, locale: NS },
QuestionComposer,
))
}

View File

@@ -0,0 +1,38 @@
/** `question` namespace dictionaries. */
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'error.incomplete': '请先完成这道问题。',
'error.unanswered': '请选择一个选项或填写自定义答案。',
'nav.prev': '上一题',
'nav.next': '下一题',
'nav.cancel': '放弃整组问题',
'option.recommended': '推荐',
'custom.placeholder': '输入你的答案',
'action.skip': '跳过本题',
'action.next': '下一题',
'plan.header': '计划待审',
'plan.approve': '确认执行',
'plan.decline': '拒绝',
'plan.discuss': '去聊天里说',
} satisfies Record<string, string>
/** The question namespace key union. */
export type QuestionKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'error.incomplete': 'Please complete this question first.',
'error.unanswered': 'Please select an option or enter a custom answer.',
'nav.prev': 'Previous question',
'nav.next': 'Next question',
'nav.cancel': 'Dismiss all questions',
'option.recommended': 'Recommended',
'custom.placeholder': 'Type your answer',
'action.skip': 'Skip this question',
'action.next': 'Next',
'plan.header': 'Plan review',
'plan.approve': 'Approve',
'plan.decline': 'Refuse',
'plan.discuss': 'Chat about it',
} satisfies Record<QuestionKey, string>

View File

@@ -0,0 +1,4 @@
declare module '*.module.css' {
const classes: Readonly<Record<string, string>>
export default classes
}

View File

@@ -0,0 +1,14 @@
/**
* Web question plugin, node half.
*
* Deliberately empty. Mounting `ask_user_question` here put it in the tools
* registry's GLOBAL layer, so every agent saw it no matter which preset
* composed it — a two-tool benchmark preset actually presented three, and a
* locally authored `bash-only` preset presented two. Rendering a question is
* a host UI capability; having the tool is an agent capability, and only a
* preset decides that. The `tool-ask-user` row belongs in the presets that
* want it (and in the TUI composition, which has no presets).
*/
/** Host plugin body — the model-facing tool is composed per preset, not here. */
export function apply(): void {}

View File

@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-user-questions`.
* @module @deepseek-ai/dsh-client-ui-user-questions/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-user-questions'
/** Cordis companion plugin name. */
export const name = 'client-ui-user-questions-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: tool and slot registrations are effects
* owned and observed by their respective registries; the host pending table is
* exercised through the public wire protocol.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns The installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,74 @@
/**
* apply wiring on a real cordis Context + SlotRegistry: QuestionComposer
* registered as the `question` entry of the conversation-declared composer
* slot with ZERO business face (data and verbs ride the dispatched carrier),
* declaration-aware activation, and fiber-teardown unregistration. Component and
* domain-face behavior is covered props-direct in question-composer.spec.tsx;
* no renderer machinery here.
*/
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
import { apply, inject } from '../src/client/index.ts'
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotRegistry).await()
const slots = ctx.get('slots') as SlotRegistry
// The composer slot exists only while its declaring entry is live.
slots.register(
{ name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never,
() => null,
)
ctx.provide('locale', new LocaleRuntime(ctx))
return { ctx, slots }
}
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['slots', 'locale'])
})
it('waits until a live entry declares the composer slot', async () => {
const ctx = new Context()
await ctx.plugin(SlotRegistry).await()
ctx.provide('locale', new LocaleRuntime(ctx))
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(ctx.slots.entries('conversation.composer')).toHaveLength(0)
ctx.slots.register(
{ name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never,
() => null,
)
await Promise.resolve()
expect(ctx.slots.entries('conversation.composer')).toHaveLength(1)
})
it('registers the question entry: routing selector, no inject face', async () => {
const { ctx, slots } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
const entry = slots.entries('conversation.composer')[0]!
expect(entry.component).toBe(QuestionComposer)
// The whole behavior surface rides the matched carrier: no business face;
// copy rides the standard locale seat.
expect(entry.inject).toBeUndefined()
expect(entry.locale).toBe('question')
// The selector narrows the chain currency: question wait in → that wait; none → null.
const select = entry.select as (owner: { interactions: readonly { kind: string }[] }) => unknown
const question = { kind: 'question' }
expect(select({ interactions: [{ kind: 'approval' }, question] })).toBe(question)
expect(select({ interactions: [{ kind: 'approval' }] })).toBeNull()
expect(select({ interactions: [] })).toBeNull()
})
it('teardown unregisters the slot entry', async () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(slots.entries('conversation.composer')).toHaveLength(1)
await fiber.dispose()
expect(slots.entries('conversation.composer')).toHaveLength(0)
})
})

View File

@@ -0,0 +1,31 @@
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it } from 'vitest'
import ToolRuntime from '@deepseek-ai/dsh-tools'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
import { apply } from '../src/index.ts'
let ctx: Context | undefined
afterEach(async () => {
await ctx?.fiber.dispose()
ctx = undefined
})
describe('ui-user-questions node plugin', () => {
it('mounts no model-facing tool', async () => {
ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
await ctx.plugin(UserQuestionService)
await ctx.plugin({ apply }).await()
// Selecting the Web question FEATURE must not hand every agent the tool.
// `ctx.tools.register` on an unscoped host context files into the global
// layer, which merges into every agent's view regardless of the preset
// that composed it — so a two-tool benchmark preset would really present
// three. The `tool-ask-user` row belongs to the presets that want it.
expect(ctx.tools.get('ask_user_question')).toBeUndefined()
})
})

View File

@@ -0,0 +1,229 @@
// @vitest-environment jsdom
// The plan-review takeover, driven through the composer entry that routes to
// it: a request carrying the intent must reach the decision card and answer
// with the asker's own option labels, and a request that does not (or cannot)
// must keep the generic question flow.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type {
ConversationSnapshot, SessionId, SessionListState, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import type { RpcReceipt } from '@deepseek-ai/dsh-api-remotes/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { planReviewOf, type QuestionComposerProps, type QuestionWait } from '../src/client/contract/slots.ts'
import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
import { en, zh } from '../src/client/locales.ts'
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
afterEach(cleanup)
const SID = 's1' as SessionId
/** Seat stub over a dictionary pair mirroring the real lookup chain: package dictionary, then common vocabulary, then the key. */
const seatOver = (dict: Record<string, string>, common: Record<string, string>): QuestionComposerProps['t'] =>
(key => dict[key] ?? common[key] ?? key)
/** Framework standard-kit stubs: the panel consumes only the locale seat. */
const kit = {
sessionId: SID,
session: undefined,
useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
useWorkspaces: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<WorkspaceListState>,
useProjection: (() => undefined) as never,
useInput: (() => { throw new Error('unused') }) as never,
inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never,
t: seatOver(zh, commonZh),
}
const PLAN = '# Ship the picker\n\n- read the store\n- render the rows\n'
/** The plan-mode request shape: one question, the plan as detail, approve named. */
const questions = (): QuestionWait['payload']['questions'] => [{
id: 'plan-review',
header: 'Plan review',
question: 'Approve this plan and leave plan mode?',
detail: PLAN,
options: [
{ label: 'Approve', description: 'Leave plan mode; the plan is carried out from the next step.' },
{ label: 'Keep planning', description: 'Stay in plan mode; feedback goes back to the model.' },
],
intent: { kind: 'plan-review', approve: 'Approve' },
}]
/** Carrier fixture over a scripted respond carrier. */
function wait(
payload: QuestionWait['payload'] = { questions: questions() },
respond = vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: true })),
) {
return { carrier: new PendingWait('question', RpcId('q-1'), SID, payload, respond), respond }
}
/** The client-response envelope respond must have received for a decision. */
function decidedEnvelope(label: string) {
return {
type: 'client-response', rpcId: RpcId('q-1'),
result: { ok: true, value: { sessionId: SID, answer: { answers: [{ id: 'plan-review', selected: [label] }] } } },
}
}
describe('planReviewOf', () => {
it('narrows a plan-review request to its decision, options included', () => {
expect(planReviewOf(questions())).toEqual({
id: 'plan-review',
question: 'Approve this plan and leave plan mode?',
plan: PLAN,
approve: { label: 'Approve', description: 'Leave plan mode; the plan is carried out from the next step.' },
decline: { label: 'Keep planning', description: 'Stay in plan mode; feedback goes back to the model.' },
})
})
it('leaves the decline absent when the asker offered approve alone', () => {
const [question] = questions()
const review = planReviewOf([{ ...question as object, options: [{ label: 'Approve' }] } as never])
expect(review?.approve).toEqual({ label: 'Approve' })
expect(review === undefined ? true : 'decline' in review).toBe(false)
})
it.each([
['a batch of more than one question', () => [...questions(), ...questions()]],
['no intent at all', () => [{ ...questions()[0] as object, intent: undefined }]],
['an intent without the plan as detail', () => [{ ...questions()[0] as object, detail: undefined }]],
['an intent whose approve names no option', () => [{
...questions()[0] as object, intent: { kind: 'plan-review', approve: 'Ship it' },
}]],
['an intent with no options at all', () => [{ ...questions()[0] as object, options: undefined }]],
// Two buttons cannot send a third label or a combination, and the generic
// flow can: an intent never costs the user a reachable answer.
['a third option the card could not offer', () => [{
...questions()[0] as object,
options: [{ label: 'Approve' }, { label: 'Keep planning' }, { label: 'Start over' }],
}]],
['a multi-select decision', () => [{ ...questions()[0] as object, multiSelect: true }]],
])('declines %s, leaving the request to the generic flow', (_case, build) => {
expect(planReviewOf(build() as never)).toBeUndefined()
})
it('declines an empty batch, which the generic flow reports as such', () => {
expect(planReviewOf([])).toBeUndefined()
})
})
describe('PlanReviewPanel', () => {
it('renders the plan under a review strip, with none of the quiz affordances', () => {
const { carrier } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
expect(document.querySelector('[data-plan-review-key="q:q-1"]')).toBeTruthy()
expect(screen.getByText(zh['plan.header'])).toBeTruthy()
// The plan renders as markdown, so its heading is a heading.
expect(screen.getByRole('heading', { name: 'Ship the picker' })).toBeTruthy()
expect(screen.getByText('render the rows')).toBeTruthy()
// The question text stays as the card's accessible name rather than a title
// that reads like a test item.
expect(screen.getByLabelText('Approve this plan and leave plan mode?')).toBeTruthy()
// No pager, no numbered options, no skip, no custom answer.
expect(screen.queryByText('1 / 1')).toBeNull()
expect(screen.queryByRole('radio')).toBeNull()
expect(screen.queryByText(zh['action.skip'])).toBeNull()
expect(screen.queryByRole('textbox')).toBeNull()
})
it('answers with the asker\'s approve label and keeps its description as the tooltip', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
const approve = screen.getByRole('button', { name: zh['plan.approve'] })
expect(approve.getAttribute('title')).toBe('Leave plan mode; the plan is carried out from the next step.')
fireEvent.click(approve)
expect(respond).toHaveBeenCalledWith(decidedEnvelope('Approve'))
// One-shot: every action locks until the host's resolved frame lands.
expect(approve.hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: zh['plan.decline'] }).hasAttribute('disabled')).toBe(true)
fireEvent.click(approve)
expect(respond).toHaveBeenCalledTimes(1)
})
it('answers with the asker\'s decline label', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
fireEvent.click(screen.getByRole('button', { name: zh['plan.decline'] }))
expect(respond).toHaveBeenCalledWith(decidedEnvelope('Keep planning'))
})
it('dismisses the request so the composer returns for a plain message', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
fireEvent.click(screen.getByRole('button', { name: zh['plan.discuss'] }))
expect(respond).toHaveBeenCalledWith({
type: 'client-response', rpcId: RpcId('q-1'),
result: {
ok: false,
error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
},
})
})
it('omits the tooltip for an option carrying no description', () => {
const { carrier } = wait({ questions: [{
...questions()[0] as object,
options: [{ label: 'Approve' }, { label: 'Keep planning' }],
}] as never })
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
expect(screen.getByRole('button', { name: zh['plan.approve'] }).hasAttribute('title')).toBe(false)
expect(screen.getByRole('button', { name: zh['plan.decline'] }).hasAttribute('title')).toBe(false)
})
it('hides the decline action when the asker offered approve alone', () => {
const { carrier } = wait({ questions: [{
...questions()[0] as object, options: [{ label: 'Approve' }],
}] as never })
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
expect(screen.queryByRole('button', { name: zh['plan.decline'] })).toBeNull()
expect(screen.getByRole('button', { name: zh['plan.approve'] })).toBeTruthy()
})
it('re-arms the actions and says why when the decision does not land', async () => {
const { carrier, respond } = wait(
{ questions: questions() },
vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: false, reason: 'not-pending' })),
)
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
fireEvent.click(screen.getByRole('button', { name: zh['plan.approve'] }))
const failure = await screen.findByText('question response rejected: not-pending')
expect(failure.getAttribute('role')).toBe('status')
// Re-armed for the retry: a lost click must not leave a dead card.
expect(screen.getByRole('button', { name: zh['plan.approve'] }).hasAttribute('disabled')).toBe(false)
fireEvent.click(screen.getByRole('button', { name: zh['plan.approve'] }))
expect(respond).toHaveBeenCalledTimes(2)
})
it('reports a non-Error transport failure as its stringified value', async () => {
// A non-Error rejection is the case under test: a carrier can reject with
// anything, and the panel must still show the user something.
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
const { carrier } = wait({ questions: questions() }, vi.fn(() => Promise.reject('socket gone')))
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
fireEvent.click(screen.getByRole('button', { name: zh['plan.discuss'] }))
expect(await screen.findByText('socket gone')).toBeTruthy()
})
it('carries the same decision surface in English', () => {
const { carrier } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} t={seatOver(en, commonEn)} />)
expect(screen.getByText('Plan review')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Approve' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Refuse' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Chat about it' })).toBeTruthy()
})
})

View File

@@ -0,0 +1,318 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type {
ConversationSnapshot, SessionId, SessionListState, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import type { RpcReceipt } from '@deepseek-ai/dsh-api-remotes/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { PendingQuestion, type QuestionComposerProps } from '../src/client/contract/slots.ts'
import { QuestionComposer, parseRecommendedLabel } from '../src/client/QuestionComposer.tsx'
import { en, zh } from '../src/client/locales.ts'
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
afterEach(cleanup)
const SID = 's1' as SessionId
/** Seat stub over a dictionary pair mirroring the real lookup chain: package dictionary, then common vocabulary, then the key. */
const seatOver = (dict: Record<string, string>, common: Record<string, string>): QuestionComposerProps['t'] =>
(key => dict[key] ?? common[key] ?? key)
/** Framework standard-kit stubs: the composer consumes only the locale seat;
* the composed props type mandates delivery of the rest (framework hooks are
* plain stubs per the client testing discipline). */
const kit = {
session: undefined,
sessionId: SID,
useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
useWorkspaces: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<WorkspaceListState>,
useProjection: (() => undefined) as never,
useInput: (() => { throw new Error('unused') }) as never,
inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never,
// The seat's key domain is question ∪ common.
t: seatOver(zh, commonZh),
}
const QUESTIONS = [
{
id: 'profile', header: '偏好', question: '选择候选人类型',
detail: '按当前空缺岗位的优先级选择。',
options: [
{ label: '工程落地型 (Recommended)', description: '优先工程交付。' },
{ label: '研究潜力型', description: '优先研究能力。' },
],
},
{
id: 'detail', question: '补充你的要求',
},
{
id: 'signals', question: '选择重要信号(可多选)', multiSelect: true,
options: [{ label: '系统设计' }, { label: '代码质量' }, { label: '产品判断' }],
},
]
/** Carrier fixture: a real PendingWait over a scripted respond carrier. */
function wait(rpcId = 'question-1', respond = vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: true }))) {
const carrier = new PendingWait(
'question', RpcId(rpcId), SID, { questions: QUESTIONS }, respond)
return { carrier, respond }
}
/** The client-response envelope respond must have received for an answer batch. */
function answeredEnvelope(rpcId: string, answers: object[]) {
return {
type: 'client-response', rpcId: RpcId(rpcId),
result: { ok: true, value: { sessionId: SID, answer: { answers } } },
}
}
describe('QuestionComposer', () => {
it('collects single, custom, and multi-select answers before one batch submit', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
expect(screen.getByText('偏好')).toBeTruthy()
expect(screen.getByText('1 / 3')).toBeTruthy()
expect(screen.getByText('推荐')).toBeTruthy()
expect(screen.getByText('工程落地型')).toBeTruthy()
const detail = screen.getByText('按当前空缺岗位的优先级选择。')
const scrollRegion = detail.closest('[data-question-scroll]')
expect(scrollRegion).toBeTruthy()
expect(scrollRegion?.contains(screen.getByRole('radio', { name: /工程落地型/ }))).toBe(true)
expect(scrollRegion?.contains(screen.getByText('下一题').closest('button'))).toBe(false)
fireEvent.keyDown(screen.getByRole('radio', { name: /工程落地型/ }), { key: 'Enter' })
expect(respond).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
expect(screen.getByText('2 / 3')).toBeTruthy()
// detail is per-question: the second question carries none.
expect(screen.queryByText('按当前空缺岗位的优先级选择。')).toBeNull()
expect(screen.queryByRole('button', { name: '填写答案' })).toBeNull()
const custom = screen.getByPlaceholderText('输入你的答案')
fireEvent.change(custom, { target: { value: '要能独立排查线上问题' } })
fireEvent.keyDown(custom, { key: 'Enter' })
expect(screen.getByText('3 / 3')).toBeTruthy()
// The model's question text renders verbatim — no marker filtering.
expect(screen.getByText('选择重要信号(可多选)')).toBeTruthy()
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('checkbox', { name: '代码质量' }))
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: ['系统设计', '代码质量', '产品判断'], custom: '沟通能力' },
]))
expect(screen.getByRole<HTMLButtonElement>('button', { name: '正在提交…' }).disabled).toBe(true)
})
it('renders plan detail through the shared assistant Markdown primitive', () => {
const carrier = new PendingWait(
'question',
RpcId('markdown-plan'),
SID,
{
questions: [{
id: 'plan',
question: '批准这个计划吗?',
detail: '# 实施计划\n\n- **先验证**现状\n- 修改 `QuestionComposer`',
options: [{ label: '批准' }],
}],
},
vi.fn(),
)
const view = render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
expect(screen.getByRole('heading', { level: 1, name: '实施计划' })).toBeTruthy()
expect(view.container.querySelector('strong')?.textContent).toBe('先验证')
expect(view.container.querySelector('code')?.textContent).toBe('QuestionComposer')
expect(view.container.querySelectorAll('li')).toHaveLength(2)
})
it('skips individual questions without discarding earlier answers', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
expect((screen.getByText('下一题').closest('button') as HTMLButtonElement).disabled).toBe(true)
fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' }))
expect(screen.getByText('2 / 3')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '跳过本题' }))
expect(screen.getByText('3 / 3')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '跳过本题' }))
expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [
{ id: 'profile', selected: ['研究潜力型'] },
{ id: 'detail', selected: [] },
{ id: 'signals', selected: [] },
]))
})
it('keeps IME Enter inside the custom input until composition finishes', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' }))
const custom = screen.getByPlaceholderText('输入你的答案')
fireEvent.change(custom, { target: { value: '中文输入' } })
fireEvent.keyDown(custom, { key: 'Enter', isComposing: true })
expect(screen.getByText('2 / 3')).toBeTruthy()
expect(respond).not.toHaveBeenCalled()
fireEvent.keyDown(custom, { key: 'Enter', keyCode: 229 })
expect(screen.getByText('2 / 3')).toBeTruthy()
expect(respond).not.toHaveBeenCalled()
fireEvent.keyDown(custom, { key: 'Enter' })
expect(screen.getByText('3 / 3')).toBeTruthy()
})
it('shows the inline custom input, reports missing answers, and supports pager navigation', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
expect(screen.getByPlaceholderText('输入你的答案')).toBeTruthy()
fireEvent.click(screen.getByRole('radio', { name: '工程落地型' }))
const emptyCustom = screen.getByPlaceholderText('输入你的答案')
fireEvent.keyDown(emptyCustom, { key: 'Enter', shiftKey: true })
expect(screen.getByText('2 / 3')).toBeTruthy()
fireEvent.keyDown(emptyCustom, { key: 'Enter' })
expect(screen.getByText('请选择一个选项或填写自定义答案。')).toBeTruthy()
fireEvent.click(screen.getByLabelText('下一题'))
fireEvent.click(screen.getByRole('checkbox', { name: '产品判断' }))
fireEvent.click(screen.getByRole('button', { name: '提交' }))
expect(screen.getByText('请先完成这道问题。')).toBeTruthy()
expect(screen.getByText('2 / 3')).toBeTruthy()
fireEvent.click(screen.getByLabelText('上一题'))
expect(screen.getByText('1 / 3')).toBeTruthy()
expect(respond).not.toHaveBeenCalled()
})
it('surfaces cancellation failures: rejected receipt text and raw transport reasons', async () => {
const respond = vi.fn()
.mockResolvedValueOnce({ accepted: false, reason: 'bad-response' })
.mockRejectedValueOnce(new Error('第二次取消失败'))
const { carrier } = wait('question-1', respond)
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
// Receipt rejection surfaces through the domain face's thrown message.
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
expect(await screen.findByText('question cancellation rejected: bad-response')).toBeTruthy()
expect(screen.getByRole<HTMLButtonElement>('button', { name: '跳过本题' }).disabled).toBe(false)
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
expect(await screen.findByText('第二次取消失败')).toBeTruthy()
})
it('surfaces transport rejection and resets local drafts for a different request', async () => {
const respond = vi.fn()
.mockRejectedValueOnce(new Error('网络中断'))
.mockRejectedValueOnce('字符串错误')
const first = wait('first', respond)
const view = render(<QuestionComposer matched={first.carrier} interactions={[first.carrier]} {...kit} />)
fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ }))
expect(screen.getByText('2 / 3')).toBeTruthy()
const second = wait('second', respond)
view.rerender(<QuestionComposer matched={second.carrier} interactions={[second.carrier]} {...kit} />)
expect(screen.getByRole('radio', { name: /研究潜力型/ }).getAttribute('aria-checked')).toBe('false')
fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
const custom = screen.getByPlaceholderText('输入你的答案')
fireEvent.change(custom, { target: { value: 'x' } })
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)
fireEvent.click(screen.getByRole('button', { name: '提交' }))
expect(await screen.findByText('字符串错误')).toBeTruthy()
})
it('renders chrome copy through the English dictionary', () => {
const respond = vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: true }))
const carrier = new PendingWait(
'question', RpcId('solo'), SID, { questions: [{ id: 'detail', question: '补充你的要求' }] }, respond)
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} t={seatOver(en, commonEn)} />)
expect(screen.getByLabelText('Dismiss all questions')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Skip this question' })).toBeTruthy()
expect(screen.getByPlaceholderText('Type your answer')).toBeTruthy()
})
it('same-key carrier replacement (baseline replay) keeps drafts', () => {
const first = wait('same-id')
const view = render(<QuestionComposer matched={first.carrier} interactions={[first.carrier]} {...kit} />)
fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ }))
expect(screen.getByText('2 / 3')).toBeTruthy()
// Replay mints a NEW carrier for the same request; same key = no remount.
const replayed = wait('same-id')
view.rerender(<QuestionComposer matched={replayed.carrier} interactions={[replayed.carrier]} {...kit} />)
expect(screen.getByText('2 / 3')).toBeTruthy()
})
})
describe('PendingQuestion domain face', () => {
it('encodes the answer batch into the ok envelope and throws on a rejected receipt', async () => {
const respond = vi.fn()
.mockResolvedValueOnce({ accepted: true })
.mockResolvedValueOnce({ accepted: false, reason: 'not-pending' })
const question = new PendingQuestion(wait('rq', respond).carrier)
const batch = { answers: [{ id: 'mode', selected: ['Fast'] }] }
await expect(question.answer(batch)).resolves.toBeUndefined()
expect(respond).toHaveBeenCalledWith(answeredEnvelope('rq', batch.answers))
await expect(question.answer(batch)).rejects.toThrow(/question response rejected: not-pending/)
})
it('encodes cancellation as the cancelled error envelope and throws on a rejected receipt', async () => {
const respond = vi.fn()
.mockResolvedValueOnce({ accepted: true })
.mockResolvedValueOnce({ accepted: false, reason: 'bad-response' })
const question = new PendingQuestion(wait('rc', respond).carrier)
await expect(question.cancel()).resolves.toBeUndefined()
expect(respond).toHaveBeenCalledWith({
type: 'client-response', rpcId: RpcId('rc'),
result: {
ok: false,
error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
},
})
await expect(question.cancel()).rejects.toThrow(/question cancellation rejected: bad-response/)
})
it('forwards key and questions from the carrier', () => {
const question = new PendingQuestion(wait('rk').carrier)
expect(question.key).toBe('q:rk')
expect(question.questions).toBe(wait('rk').carrier.payload.questions)
})
})
describe('parseRecommendedLabel', () => {
it('recognizes English and Chinese suffixes without changing ordinary labels', () => {
expect(parseRecommendedLabel('Fast (Recommended)')).toEqual({ label: 'Fast', recommended: true })
expect(parseRecommendedLabel('稳妥(推荐)')).toEqual({ label: '稳妥', recommended: true })
expect(parseRecommendedLabel('稳妥 (推荐)')).toEqual({ label: '稳妥', recommended: true })
expect(parseRecommendedLabel('Plain')).toEqual({ label: 'Plain', recommended: false })
})
})

View File

@@ -0,0 +1,36 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../api/remotes/tsconfig.client.json"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../locale"
},
{
"path": "../runtime"
},
{
"path": "../ui-conversation"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slots"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-user-questions', ['lib/types/index.js', 'lib/types/invariant.js'])