From 03889cee1af5a761976e7dceab87a1af4546194c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 22 Jul 2026 23:39:50 +0800 Subject: [PATCH 01/12] feat(gui): add ask-user question composer --- .../feature/2026-06-25-ask-user-question.md | 10 +- apps/web/tests/smoke-fixture.e2e.ts | 53 ++- apps/web/tests/smoke-real.e2e.ts | 4 +- docs/config-catalog.md | 1 + docs/core-data-structures/user-interaction.md | 4 +- docs/module-graph.md | 3 + .../client/connection/src/client/fixture.ts | 61 +++- .../client/connection/tests/fixture.spec.ts | 37 +- .../src/client/sessions/conversation.ts | 11 +- .../runtime/src/client/sessions/session.ts | 33 +- packages/client/runtime/tests/fake-api.ts | 7 +- packages/client/runtime/tests/session.spec.ts | 24 ++ packages/client/ui-conversation/README.md | 4 +- .../ui-conversation/src/client/apply.ts | 3 + .../src/client/chat/ChatView.tsx | 4 +- .../src/client/chat/PendingCard.tsx | 20 +- .../src/client/contract/slots.ts | 10 +- .../ui-conversation/src/client/index.ts | 13 +- .../src/client/skeleton/ConversationRoot.tsx | 43 ++- .../ui-conversation/tests/chat-view.spec.tsx | 11 +- .../tests/coverage-tails.spec.tsx | 11 +- .../tests/skeleton-branches.spec.tsx | 7 +- .../ui-conversation/tests/skeleton.spec.tsx | 32 +- packages/client/ui-question/README.md | 20 ++ packages/client/ui-question/package.json | 66 ++++ .../src/client/QuestionComposer.module.css | 329 ++++++++++++++++++ .../src/client/QuestionComposer.tsx | 308 ++++++++++++++++ .../client/ui-question/src/client/index.ts | 47 +++ .../client/ui-question/src/css-modules.d.ts | 4 + packages/client/ui-question/src/index.ts | 17 + packages/client/ui-question/src/invariant.ts | 31 ++ .../ui-question/tests/browser-plugin.spec.ts | 58 +++ .../ui-question/tests/node-plugin.spec.ts | 28 ++ .../tests/question-composer.spec.tsx | 198 +++++++++++ packages/client/ui-question/tsconfig.json | 43 +++ packages/client/ui-question/tsdown.config.ts | 3 + .../client/ui-trajectory/tests/views.spec.tsx | 16 +- packages/host/apiproxy/src/api/rpc.schema.ts | 1 + packages/host/apiproxy/src/api/rpc.ts | 1 + .../host/apiproxy/tests/rpc-schemas.spec.ts | 1 + packages/host/runtime/README.md | 8 +- packages/host/runtime/package.json | 2 + packages/host/runtime/src/api-proxy.ts | 154 +++++++- packages/host/runtime/src/boot.ts | 2 + packages/host/runtime/src/web-plugins.ts | 11 +- .../host/runtime/tests/api-proxy-cold.spec.ts | 3 + .../host/runtime/tests/api-proxy-view.spec.ts | 2 + .../host/runtime/tests/host-runtime.spec.ts | 175 +++++++++- .../host/runtime/tests/web-plugins.e2e.ts | 17 +- .../host/runtime/tests/web-plugins.spec.ts | 6 +- packages/host/runtime/tsconfig.json | 3 + packages/ui/user-interaction/README.md | 2 +- packages/ui/user-interaction/src/types.ts | 2 +- pnpm-lock.yaml | 52 +++ .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 1 + tsconfig.build.json | 1 + tsconfig.client.json | 1 + 58 files changed, 1905 insertions(+), 115 deletions(-) create mode 100644 packages/client/ui-question/README.md create mode 100644 packages/client/ui-question/package.json create mode 100644 packages/client/ui-question/src/client/QuestionComposer.module.css create mode 100644 packages/client/ui-question/src/client/QuestionComposer.tsx create mode 100644 packages/client/ui-question/src/client/index.ts create mode 100644 packages/client/ui-question/src/css-modules.d.ts create mode 100644 packages/client/ui-question/src/index.ts create mode 100644 packages/client/ui-question/src/invariant.ts create mode 100644 packages/client/ui-question/tests/browser-plugin.spec.ts create mode 100644 packages/client/ui-question/tests/node-plugin.spec.ts create mode 100644 packages/client/ui-question/tests/question-composer.spec.tsx create mode 100644 packages/client/ui-question/tsconfig.json create mode 100644 packages/client/ui-question/tsdown.config.ts diff --git a/.agents/notes/implemented/feature/2026-06-25-ask-user-question.md b/.agents/notes/implemented/feature/2026-06-25-ask-user-question.md index e1c5d03c08..cd1a423499 100644 --- a/.agents/notes/implemented/feature/2026-06-25-ask-user-question.md +++ b/.agents/notes/implemented/feature/2026-06-25-ask-user-question.md @@ -14,12 +14,16 @@ Introduce `dsh-user-interaction` as the provider-neutral interface package for ` The model-facing request vocabulary is deliberately aligned with the product-research schema: `ask_user_question({ questions: [{ id, question, header?, options?: [{ label, description? }], multi_select? }] })`. `id` is supplied per question and echoed in the result so a batch can be routed without relying on question text. `label` is both user-facing display text and the selected value returned to the model; there is no separate `value`, no `recommended`, no `allow_custom`, and no `desc` alias. -Providers return `{ answers: [{ id, selected, custom? }] }`. `selected` is always an array of selected option labels, so single-select and `multi_select` answers share one result shape. `custom` carries a free-text "Other" answer; optionless questions collect `custom` directly. When `custom` is present, it overrides any selected choices and `selected` is empty. +Providers return `{ answers: [{ id, selected, custom? }] }`. `selected` is always an array of selected option labels, so single-select and `multi_select` answers share one result shape. `custom` carries a free-text "Other" answer; optionless questions collect `custom` directly. When `custom` is present, it overrides any selected choices and `selected` is empty. A provider that supports partial completion represents a deliberately skipped item with the existing `{ id, selected: [] }` shape, preserving the other answers without extending the tool result vocabulary. `UserInteractionError` extends `HarnessError`, so failures such as `NO_PROVIDER`, `ASK_ABORTED`, ACP cancellation, or missing session routing survive `ctx.tools.execute()` as machine-routable `{ name, code }` tool errors. This matches the structured-error taxonomy and lets the model or a wrapping plugin distinguish "user cancelled" from a generic thrown exception. ## UI mappings +`dsh web` mounts `dsh-client-ui-question`, whose host half opts the Web product into the model-facing tool and whose browser half registers a `question` entry in the conversation-owned keyed composer slot. `createApiProxy` implements the Web provider with a process-memory pending table keyed by a host-minted rpcId. It registers the wait before broadcasting `question/requested`, replays the same id on every mux reopen, validates the session and complete answer batch before claiming it, and broadcasts `question/resolved` after answer, cancellation, abort, or disposal. Claiming deletes the entry synchronously, so the first valid response wins and duplicate or late responses return `not-pending`. + +The Web composer shows one question at a time while retaining every request in the session object layer. It supports single-select, multi-select, optionless or explicit custom answers, description text, and a visual recommendation badge without selecting the recommendation automatically. Single-select choices advance to the next item immediately, and Enter submits when every item is answered or explicitly skipped; Enter during IME composition only confirms the input candidate. The footer skips only the current item and preserves earlier drafts; the close control rejects the whole tool call with `ASK_CANCELLED`. The normal composer returns only after the host's resolved frame removes the pending item. + `dsh-tui` renders each question as a keyboard overlay, shows option descriptions, supports single- and multi-select choices plus free-form custom answers, and rejects pending questions on abort, provider disposal, or terminal shutdown. Batched and simultaneous requests are queued so one overlay owns keyboard focus at a time. `dsh-acp` provides the same seam for ACP sessions. It resolves the calling `Agent` through `ownedRecord`, requiring the forward session-map record at `agent.session.id` to own that exact agent object, and calls ACP `unstable_createElicitation` with a session-scoped form for each question. Single-select options become a `choice` string enum; `multi_select` options become a `choice` array enum; optionless questions use a required `custom` text field. If the client returns both `choice` and non-empty `custom`, the custom answer wins. ACP `decline`/`cancel`, a missing answer, a missing session, and a client without elicitation support all become structured `UserInteractionError`s. @@ -42,8 +46,8 @@ ACP elicitation is currently marked unstable in the SDK. The fallback is still s The feature gives the model a powerful pause primitive, so prompt guidance matters. The tool description tells the model to ask concise questions and use options when possible. Product policy can later wrap `tools/execute` to restrict when the tool is allowed, but the loop should not special-case it. -`dsh-user-interaction` and `dsh-tool-ask-user` both live in `packages/ui` because they form one product-facing human-interaction capability. `agent-core` does not load either the tool or a provider. `dsh-tui-demo` opts into the seam, TUI provider, and model-facing tool. `acp-agent` keeps only the `userInteraction` seam/provider by default: ACP elicitation support is still client-dependent, so an ACP leaf must opt into the model-facing tool deliberately once its client can complete elicitation requests. +`dsh-user-interaction` and `dsh-tool-ask-user` both live in `packages/ui` because they form one product-facing human-interaction capability. `agent-core` does not load either the tool or a provider. `dsh-tui-demo` opts into the seam, TUI provider, and model-facing tool. `dsh web` boots the seam/provider in the host runtime and exposes the tool through the selected Web question plugin. `acp-agent` keeps only the `userInteraction` seam/provider by default: ACP elicitation support is still client-dependent, so an ACP leaf must opt into the model-facing tool deliberately once its client can complete elicitation requests. ## Testing -Unit coverage pins provider registration/disposal, duplicate-provider rejection, abort-before-provider, empty-question rejection, structured tool errors through `ctx.tools.execute()`, batched answers, multi-select answers, custom answers, and the model schema including the removal of `value`, `recommended`, `allow_custom`, and `desc`. TUI tests cover option descriptions, queued requests, shutdown/abort cleanup, optionless free-form input, invalid choices, duplicate multi-select selections, and batched question flows. ACP bridge tests drive a real in-memory ACP connection with the real `ask_user_question` tool and verify selected-option, custom-overrides-choice, multi-select, and optionless free-form elicitation paths continue the agent loop. +Unit coverage pins provider registration/disposal, duplicate-provider rejection, abort-before-provider, empty-question rejection, structured tool errors through `ctx.tools.execute()`, batched answers, multi-select answers, custom answers, explicit per-item skips, and the model schema including the removal of `value`, `recommended`, `allow_custom`, and `desc`. TUI tests cover option descriptions, queued requests, shutdown/abort cleanup, optionless free-form input, invalid choices, duplicate multi-select selections, and batched question flows. ACP bridge tests drive a real in-memory ACP connection with the real `ask_user_question` tool and verify selected-option, custom-overrides-choice, multi-select, and optionless free-form elicitation paths continue the agent loop. Web tests pin stable-id replay, response validation, first-wins settlement, duplicate and late responses, whole-request cancellation versus owner abort, single-select advance, IME-safe Enter submission, per-item skip preservation, composer takeover, structured batch submission, and restoration of the normal composer. diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index 0c32be5310..5f2d870a4c 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -1,10 +1,10 @@ // Keyless boot-chain smoke over the REAL carrier: startWebServer + web-plugins // registry surface + __DSH_BOOT__ injection + built shell dist in a real // chromium. First describe: manifest injection + fail-loud half. Second -// describe: the settled success pass — five REAL tsdown bundles (the -// infrastructure four + layout) load through the DI chain in ?fixture mode -// and the three-column frame appears in one flip. The full conversation -// round lands in smoke-real under the W5 real-host standard. +// describe: the settled success pass — all nine REAL tsdown bundles load +// through the DI chain in ?fixture mode, the three-column frame appears in +// one flip, and the resident question completes through the real UI stack. +// The full model round lands in smoke-real under the W5 real-host standard. import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' @@ -17,13 +17,17 @@ import { DIST_INDEX, probeFreePort, requireDist, saveFailureShot } from './suppo const bundlePath = (dir: string): string => fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url)) -/** id ↔ bundle table for the success pass (immediately four + layout). */ +/** id ↔ bundle table for the success pass (the complete Web UI assembly). */ const REAL_PLUGINS: { id: string; dir: string; inject: string[]; immediately?: boolean }[] = [ { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-question', dir: 'ui-question', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, + { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, ] /** Manifest served by the fake registry: one live bundle row, one missing row. */ @@ -90,7 +94,7 @@ describe('web boot chain (keyless, real carrier)', () => { }) }) -describe('web boot chain success pass (keyless, five real bundles, ?fixture)', () => { +describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', () => { const missing = REAL_PLUGINS.filter((p) => !existsSync(bundlePath(p.dir))) let server: Awaited> let browser: Browser @@ -141,6 +145,43 @@ describe('web boot chain success pass (keyless, five real bundles, ?fixture)', ( expect(owners).toContain('@deepseek-ai/dsh-client-ui-layout') }) + it('renders and completes the resident question through the composer slot', async () => { + onTestFailed(() => saveFailureShot(page, 'smoke-question-composer')) + await page.getByText('fixture', { exact: true }).click() + await page.locator('[role="treeitem"]').nth(1).click() + const composer = page.locator('[data-question-rpc-id]') + await composer.waitFor({ timeout: 15_000 }) + expect({ + question: await composer.getByRole('heading').innerText(), + progress: await composer.getByText('1 / 3', { exact: true }).innerText(), + options: await composer.getByRole('radio').allTextContents(), + custom: await composer.getByRole('button', { name: '其他,请填写自定义答案' }).innerText(), + }).toMatchInlineSnapshot(` + { + "custom": "其他,请填写自定义答案", + "options": [ + "1工程落地型推荐更看重能直接做 runtime、tool executor、sandbox、trace 和线上问题排查。", + "2研究潜力型更看重 Agent 理解、训练评测思路和长期成长空间。", + "3均衡型同时要求工程能力和 Agent 认知,但可能筛选门槛更高。", + ], + "progress": "1 / 3", + "question": "你现在更想招哪类 Agent/Harness 候选人?", + } + `) + + await composer.getByRole('radio', { name: '工程落地型' }).click() + await composer.getByText('2 / 3', { exact: true }).waitFor() + await composer.getByRole('button', { name: '跳过本题', exact: true }).click() + await composer.getByRole('checkbox', { name: '系统设计' }).click() + await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).click() + await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).press('Enter') + + await composer.waitFor({ state: 'detached' }) + const restoredInput = page.locator('textarea[placeholder]') + await restoredInput.waitFor() + expect(await restoredInput.getAttribute('placeholder')).toBe('回复生成中,可停止后再输入') + }) + it('stayed clean: no page errors across the whole load chain', () => { expect(pageErrors).toEqual([]) }) diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 4e280af0c7..8af42072f8 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -75,10 +75,10 @@ async function detailsTrack(page: Page): Promise { return Number(cols.split(' ').pop()!.replace('px', '')) } -// Readiness gate: `dsh web` serves ALL eight manifest plugins; until every UI +// Readiness gate: `dsh web` serves ALL nine manifest plugins; until every UI // plugin's client bundle exists and exports apply, the loader fail-louds and // the frame never appears. -const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'i18n', 'ui-layout', 'ui-sidebar', 'ui-conversation', 'ui-trajectory'] +const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'i18n', 'ui-layout', 'ui-sidebar', 'ui-conversation', 'ui-question', 'ui-trajectory'] const notReady = UI_PLUGIN_DIRS.filter((dir) => { const bundle = join(REPO_ROOT, 'packages/client', dir, 'lib/client.js') return !existsSync(bundle) || !readFileSync(bundle, 'utf8').includes('exports.apply') diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 730ef09965..8f50113ee3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1743,6 +1743,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-question` — requires `tools` · `userInteraction` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) - `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) - `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts)) diff --git a/docs/core-data-structures/user-interaction.md b/docs/core-data-structures/user-interaction.md index 46e17f3f83..a65d08e1aa 100644 --- a/docs/core-data-structures/user-interaction.md +++ b/docs/core-data-structures/user-interaction.md @@ -56,14 +56,14 @@ interface AskUserQuestionRequest { ## Answer -Providers return one answer per answered 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. +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. ```ts type-equiv /** Answer to one question. */ interface AskUserQuestionAnswerItem { /** The answered question id. */ id: string - /** Selected option labels. Empty when the answer is purely custom text. */ + /** Selected option labels. Empty for custom or unanswered choices. */ selected: string[] /** Optional free-text "Other" answer. */ custom?: string diff --git a/docs/module-graph.md b/docs/module-graph.md index fd0fe09a12..4188572941 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -133,6 +133,7 @@ flowchart TD pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_layout["client-ui-layout"] pkg_client_ui_primitives["client-ui-primitives"] + pkg_client_ui_question["client-ui-question"] pkg_client_ui_sidebar["client-ui-sidebar"] pkg_client_ui_slots["client-ui-slots"] pkg_client_ui_theme["client-ui-theme"] @@ -207,6 +208,7 @@ flowchart TD pkg_client_ui_conversation --> pkg_invariants pkg_client_ui_layout --> pkg_invariants pkg_client_ui_primitives --> pkg_invariants + pkg_client_ui_question --> pkg_invariants pkg_client_ui_sidebar --> pkg_invariants pkg_client_ui_slots --> pkg_invariants pkg_client_ui_theme --> pkg_invariants @@ -706,6 +708,7 @@ flowchart TD | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) | +| [`client-ui-question`](../packages/client/ui-question) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`invariants`](../packages/support/invariants) | diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 7d4a93e888..aa96bf09a2 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2,8 +2,8 @@ // RpcRequest

and returns RpcResponse (echoing the rpcId); streams yield RpcRequest // (the fixture IS the fake server, so it mints frame rpcIds); root respond takes ClientResponse // and returns RpcReceipt. fx-alpha carries a hand-built history script (60 turns, pageable); -// prompt triggers a chunked streaming replay; cancel stops the replay; one resident pending -// approval (placeholder-card material, subscribed-baseline-replay semantics: stable rpcId reuse). +// prompt triggers a chunked streaming replay; cancel stops the replay; resident pending +// approval/question requests exercise replay and composer takeover with stable rpcIds. import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' @@ -249,6 +249,40 @@ export function createFixtureApi(): ApiProxy { const mint = (): ReturnType => RpcId(`fx-rpc-${nextRpc++}`) /** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */ const pendingApprovalRpcId = mint() + const pendingQuestionRpcId = mint() + let questionPending = true + const fixtureQuestions: Extract['questions'] = [ + { + id: 'harness-profile', + header: '偏好', + question: '你现在更想招哪类 Agent/Harness 候选人?', + options: [ + { label: '工程落地型 (Recommended)', description: '更看重能直接做 runtime、tool executor、sandbox、trace 和线上问题排查。' }, + { label: '研究潜力型', description: '更看重 Agent 理解、训练评测思路和长期成长空间。' }, + { label: '均衡型', description: '同时要求工程能力和 Agent 认知,但可能筛选门槛更高。' }, + ], + }, + { + id: 'work-mode', + header: '方式', + question: '你希望候选人优先展示哪种工作方式?', + options: [ + { label: '先做小型原型 (Recommended)', description: '用可运行结果尽快验证关键假设。' }, + { label: '先写完整设计', description: '先收敛边界、协议和风险,再开始实现。' }, + ], + }, + { + id: 'signals', + header: '信号', + question: '哪些面试信号最重要?', + multiSelect: true, + options: [ + { label: '系统设计' }, + { label: '代码质量' }, + { label: 'Agent 产品判断' }, + ], + }, + ] const muxConns = new Set>() const hostConns = new Set>() @@ -429,7 +463,7 @@ export function createFixtureApi(): ApiProxy { muxConns.add(conn) const breakNow = (): void => { conn.breakNow() } streamBreakers.add(breakNow) - // Open baseline: subscribed for attached (running) sessions + pending approval replay (stable rpcId). + // Open baseline: subscribed sessions + pending interactions replayed with stable rpcIds. for (const s of sessions) { if (!s.running) continue conn.push({ rpcId: mint(), payload: { type: 'session/subscribed', sessionId: s.sessionId, lastSeq: (logs.get(s.sessionId)?.length ?? 0) - 1 } }) @@ -442,6 +476,14 @@ export function createFixtureApi(): ApiProxy { toolName: 'dangerous_tool', reason: 'fixture 常驻占位审批(可见不可答)', }, }) + if (questionPending) { + conn.push({ + rpcId: pendingQuestionRpcId, + payload: { + type: 'question/requested', sessionId: sid('fx-alpha'), questions: fixtureQuestions, + }, + }) + } try { yield* conn.drain(signal) } finally { @@ -471,9 +513,16 @@ export function createFixtureApi(): ApiProxy { }, }, respond(message: ClientResponse): Promise { - // The v1 UI never answers (PendingCard is visible but not answerable); implemented for type completeness, always not-pending. - void message - return Promise.resolve({ accepted: false, reason: 'not-pending' }) + if (!questionPending || message.rpcId !== pendingQuestionRpcId) { + return Promise.resolve({ accepted: false, reason: 'not-pending' }) + } + questionPending = false + emitMux({ + type: 'question/resolved', sessionId: sid('fx-alpha'), + questionRpcId: pendingQuestionRpcId, + outcome: message.result.ok ? 'answered' : 'cancelled', + }) + return Promise.resolve({ accepted: true }) }, } } diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index c50921a44d..b7f5919785 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -148,14 +148,14 @@ describe('createFixtureApi', () => { expect(types.at(-1)).toBe('turn/end') // steer did not restart the turn }) - it('mux open replays the baseline: subscribed for running sessions + the resident approval with a stable rpcId', async () => { + it('mux open replays subscribed sessions and resident interactions with stable rpcIds', async () => { const api = createFixtureApi() const openOnce = async (): Promise[]> => { const abort = new AbortController() const envelopes: RpcRequest[] = [] for await (const envelope of api.events.mux(req({}), abort.signal)) { envelopes.push(envelope) - if (envelopes.length >= 2) abort.abort() + if (envelopes.length >= 3) abort.abort() } return envelopes } @@ -165,6 +165,8 @@ describe('createFixtureApi', () => { expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0) expect(first[1]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) expect(second[1]?.rpcId).toBe(first[1]?.rpcId) // stable rpcId across replays (host replay semantics) + expect(first[2]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' }) + expect(second[2]?.rpcId).toBe(first[2]?.rpcId) }) it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => { @@ -217,9 +219,38 @@ describe('createFixtureApi', () => { } }) - it('respond is a typed stub: always not-pending', async () => { + it('respond resolves the resident question once and rejects duplicate or unrelated ids', async () => { const api = createFixtureApi() expect(await api.respond({ type: 'client-response', rpcId: RpcId('x'), result: { ok: true, value: {} } })).toEqual({ accepted: false, reason: 'not-pending' }) + const abort = new AbortController() + let question: RpcRequest | undefined + for await (const envelope of api.events.mux(req({}), abort.signal)) { + if (envelope.payload.type !== 'question/requested') continue + question = envelope + abort.abort() + } + if (question === undefined) throw new Error('fixture question missing') + const response = { type: 'client-response' as const, rpcId: question.rpcId, result: { ok: true as const, value: {} } } + expect(await api.respond(response)).toEqual({ accepted: true }) + expect(await api.respond(response)).toEqual({ accepted: false, reason: 'not-pending' }) + + const replayAbort = new AbortController() + const replayed = await collect(api.events.mux(req({}), replayAbort.signal), replayAbort, frames => frames.length === 2) + expect(replayed.every(frame => frame.type !== 'question/requested')).toBe(true) + + const cancelledApi = createFixtureApi() + const cancelAbort = new AbortController() + let cancelQuestion: RpcRequest | undefined + for await (const envelope of cancelledApi.events.mux(req({}), cancelAbort.signal)) { + if (envelope.payload.type !== 'question/requested') continue + cancelQuestion = envelope + cancelAbort.abort() + } + if (cancelQuestion === undefined) throw new Error('fixture cancellation question missing') + expect(await cancelledApi.respond({ + type: 'client-response', rpcId: cancelQuestion.rpcId, + result: { ok: false, error: { code: 'cancelled', message: 'skip', details: {} } }, + })).toEqual({ accepted: true }) }) it('describe answers the fixture identity', async () => { diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 18c5501972..4feb4c1894 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -4,7 +4,7 @@ // string here (narrow to real brands when convenient). import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { RpcError, RpcId, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { MuxFrame, RpcError, RpcId, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' /** Assistant content blocks sorted by what the UI cares about * (text body / collapsible reasoning / tool-call card head / other fallback). */ @@ -121,11 +121,14 @@ export interface RunningToolCall { callView: ToolCallView | null } -/** Approval/question placeholder cards (visible, not answerable; - * rpcId = the requested frame's envelope id, the future respond backfill key). */ +/** Approval/question pending state; rpcId is the requested frame's response-backfill key. */ export type PendingInteraction = | { kind: 'approval'; rpcId: RpcId; approvalId: string; toolName: string; callId?: string; reason?: string } - | { kind: 'question'; rpcId: RpcId; questions: readonly unknown[] } + | { + kind: 'question' + rpcId: RpcId + questions: readonly Extract['questions'][number][] + } /** In-progress assistant output (chunk accumulator product). */ export interface PartialAssistant { diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 0934118bd1..b13a1a5bee 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -5,7 +5,10 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, SessionId, ToolEventView } from '@deepseek-ai/dsh-client-connection/client' +import type { + HistoryEntry, IApiClient, MuxFrame, QuestionResponsePayload, RpcError, RpcId, RpcReceipt, RpcResult, + SessionId, ToolEventView, +} from '@deepseek-ai/dsh-client-connection/client' import { transportError } from '@deepseek-ai/dsh-client-connection/client' import type { ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' @@ -120,6 +123,34 @@ export class Session implements ObservableSnapshot { return result } + /** + * Answer one host-owned question wait; pending clears only on the authoritative resolved frame. + * @param rpcId - Stable id from the requested frame. + * @param answer - Complete structured answer batch. + * @returns Carrier receipt; rejection leaves pending state unchanged. + */ + answerQuestion(rpcId: RpcId, answer: QuestionResponsePayload['answer']): Promise { + return this.api.respond({ + type: 'client-response', rpcId, + result: { ok: true, value: { sessionId: this.sessionId, answer } }, + }) + } + + /** + * Cancel one host-owned question wait without encoding closure as skipped answers. + * @param rpcId - Stable id from the requested frame. + * @returns Carrier receipt; rejection leaves pending state unchanged. + */ + cancelQuestion(rpcId: RpcId): Promise { + return this.api.respond({ + type: 'client-response', rpcId, + result: { + ok: false, + error: { code: 'cancelled', message: 'the user closed this question request', details: {} }, + }, + }) + } + /** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */ open(): Promise { if (this.openState === 'open') return Promise.resolve() diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index c13ef09fcb..839cf2a447 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -2,7 +2,7 @@ // data source on a real clock; behavior tests need per-case responses and // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. import type { - HostFrame, IApiClient, MuxFrame, RpcError, RpcRequest, RpcResponse, SessionId, + ClientResponse, HostFrame, IApiClient, MuxFrame, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, } from '@deepseek-ai/dsh-client-connection/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' @@ -54,6 +54,7 @@ export class FakeApiClient implements IApiClient { onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onDescribe: (payload: unknown) => Promise> = () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) + onRespond: (message: ClientResponse) => Promise = () => Promise.resolve({ accepted: true }) private readonly muxConns: StreamConn[] = [] private readonly hostConns: StreamConn[] = [] @@ -93,8 +94,8 @@ export class FakeApiClient implements IApiClient { host: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => this.openStream(this.hostConns, signal, onOpen), } - respond(): Promise<{ accepted: false; reason: 'not-pending' }> { - return Promise.resolve({ accepted: false, reason: 'not-pending' }) + respond(message: ClientResponse): Promise { + return this.record('respond', message, this.onRespond(message)) } /** Push one mux frame to every open mux stream (rpcId minted unless pinned by the case). */ diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 149f73c1fc..b5309c8e8c 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -251,6 +251,30 @@ describe('pending interactions', () => { session.handleMuxEnvelope('ry' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'rq' as never, outcome: 'answered' }) expect(session.getSnapshot().pending).toEqual([]) }) + + it('backfills the requested rpcId for structured answers and explicit cancellation', async () => { + const { api, session } = makeSession() + await session.answerQuestion('rq-answer' as never, { + answers: [{ id: 'mode', selected: ['Fast'] }], + }) + await session.cancelQuestion('rq-cancel' as never) + expect(api.callsOf('respond')).toEqual([ + { + type: 'client-response', rpcId: 'rq-answer', + result: { + ok: true, + value: { sessionId: SID, answer: { answers: [{ id: 'mode', selected: ['Fast'] }] } }, + }, + }, + { + type: 'client-response', rpcId: 'rq-cancel', + result: { + ok: false, + error: { code: 'cancelled', message: 'the user closed this question request', details: {} }, + }, + }, + ]) + }) }) describe('remaining branches', () => { diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index c75077d11d..67af7c07b1 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-ui-conversation -Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation), ctx.toolviews named registry with bash samples, minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7. +Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation), ctx.toolviews named registry with bash samples, minimal details panel, scope-addressed ConversationService. The keyed `conversation.composer` slot lets pending interaction features replace InputBar without moving interaction state into the skeleton. Contract: api-contracts v3 §7. `src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` composed slot props, `views.ts` view ring, `toolview.ts` tool ring, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. @@ -18,5 +18,5 @@ None; this package neither assembles nor sends a provider request. - **Details panel is the minimal form** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. - **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented. - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. -- **Approval/question cards are display-only placeholders** — web-side answering (composer takeover panel) is the P-II approvals project. +- **Approval cards are display-only placeholders** — question requests use the composer slot, while Web approval answering remains deferred. - **Module-level toolview caches are single-bundle state** — the inject cache and registry maps must reach cross-bundle consumers through the package export surface and loader module table, never by a second bundle copy. diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index a600789a20..8153034497 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -62,6 +62,8 @@ export function apply(ctx: Context): void { const layout = need(ctx, 'layout') const i18n = need(ctx, 'i18n') const slots = need(ctx, 'slots') + slots.define('conversation.composer', { kind: 'keyed', scope: 'session' }) + const composerSlots = scopedSlots(slots.core, 'conversation.composer') const conversation = new ConversationService(ctx) const toolviews = new ToolViewRegistry() @@ -153,6 +155,7 @@ export function apply(ctx: Context): void { } return createElement(Fragment, null, ...children) }, + slots: composerSlots, } return injected } diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index b8ea969829..faa6323656 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -270,7 +270,9 @@ export function createChatView(deps: ChatViewDeps): FC { ))} )} - {pending.map((item) => )} + {pending.map((item) => item.kind === 'approval' + ? + : null)} {!atBottom && ( diff --git a/packages/client/ui-conversation/src/client/chat/PendingCard.tsx b/packages/client/ui-conversation/src/client/chat/PendingCard.tsx index 56b886c9ad..408faa7ddf 100644 --- a/packages/client/ui-conversation/src/client/chat/PendingCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/PendingCard.tsx @@ -1,30 +1,18 @@ -// PendingCard: approval/question placeholder card (visible, not answerable — -// the composer-takeover approval panel is a P-II item; wire pending semantics -// already exist so the flow must show them). +// PendingCard: approval placeholder card. Questions take over the composer. import { memo } from 'react' import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client' -import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives' import css from './PendingCard.module.css' export interface PendingCardProps { - item: PendingInteraction + item: Extract } export const PendingCard = memo(function PendingCard({ item }: PendingCardProps) { return (

- {item.kind === 'approval' ? ( - <> -
等待审批:{item.toolName}
- {item.reason !== undefined &&
{item.reason}
} - - ) : ( - <> -
等待回答({item.questions.length} 题)
- - - )} +
等待审批:{item.toolName}
+ {item.reason !== undefined &&
{item.reason}
}
请在原客户端处理(web 端作答后续里程碑提供)
) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 276ae1ec30..d05c7f319b 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -8,7 +8,8 @@ * standard share & own injected share. */ import type { ReactNode } from 'react' -import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' +import type { PendingInteraction, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' +import type { ScopedSlots } from '@deepseek-ai/dsh-client-ui-slots' import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-web-react' import type { ConvOwnerProps, DetailsOwnerProps, EmptyOwnerProps } from '@deepseek-ai/dsh-client-ui-layout/client' import type { SelectionTarget, ViewEntry, ViewId } from './views.ts' @@ -38,6 +39,13 @@ export interface ConversationInjected { } /** Renders the active view's body (the owner closes over ConvViewProps assembly). */ renderView: (entry: ViewEntry) => ReactNode + /** Feature-owned composer replacements, dispatched by pending interaction kind. */ + slots: ScopedSlots<'conversation.composer'> +} + +/** Question-composer owner share supplied by ConversationRoot. */ +export interface QuestionComposerOwnerProps { + interaction: Extract } /** Full conversation-slot component props: owner share & standard share & injected share. */ diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 17e9ed88a8..d3b20775a8 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -8,6 +8,7 @@ */ import type { ConversationService } from './service.ts' import type { ToolViewRegistry } from './toolviews/registry.ts' +import type { QuestionComposerOwnerProps } from './contract/slots.ts' export { apply, inject } from './apply.ts' export { ConversationService } from './service.ts' @@ -22,7 +23,7 @@ export type { } from './contract/toolview.ts' export type { ConversationInjected, ConversationSlotProps, DetailsInjected, DetailsSlotProps, - EmptyStateInjected, EmptyStateSlotProps, + EmptyStateInjected, EmptyStateSlotProps, QuestionComposerOwnerProps, } from './contract/slots.ts' export { ConversationRoot } from './skeleton/ConversationRoot.tsx' @@ -40,3 +41,13 @@ declare module 'cordis' { toolviews: ToolViewRegistry } } + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + 'conversation.composer': { + kind: 'keyed' + scope: 'session' + owner: QuestionComposerOwnerProps + } + } +} diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 540b76bda7..8f548e54b4 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -7,6 +7,7 @@ import { useSyncExternalStore } from 'react' import clsx from 'clsx' +import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSlotProps } from '../contract/slots.ts' import { InputBar } from './InputBar.tsx' import type { InputBarError } from './InputBar.tsx' @@ -20,7 +21,7 @@ import css from './ConversationRoot.module.css' export type ConversationRootProps = ConversationSlotProps export function ConversationRoot({ - sessionId, useSession, useAncestry, views, useActiveView, composer, actions, renderView, + sessionId, useSession, useAncestry, views, useActiveView, composer, actions, renderView, slots, }: ConversationRootProps) { useSyncExternalStore(views.subscribe, views.version) const list = views.list() @@ -33,6 +34,9 @@ export function ConversationRoot({ const removed = useSession(s => (s as { removed: boolean }).removed) const promptError = useSession(s => (s as { promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null }).promptError) const turns = useSession(s => countTurns(s as { nodes: readonly { kind: string }[] })) + const question = useSession(s => ( + s as { pending: readonly PendingInteraction[] } + ).pending.find(item => item.kind === 'question')) const error: InputBarError | null = promptError === null ? null @@ -87,20 +91,37 @@ export function ConversationRoot({ {active !== undefined && renderView(active)} - + {question?.kind === 'question' + ? slots.renderSlot('conversation.composer', { interaction: question }, { + entryKey: 'question', + fallback: , + }) + : } ) } +function ComposerInput({ draft, running, removed, error, composer }: { + draft: string + running: boolean + removed: boolean + error: InputBarError | null + composer: ConversationSlotProps['composer'] +}) { + return ( + + ) +} + /** Turn count = user message nodes in the window (display meta; exact host count deferred). */ function countTurns(s: { nodes: readonly { kind: string }[] }): number { let n = 0 diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index af4f80c0b4..9364979d42 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -295,11 +295,18 @@ describe('ChatView', () => { expect(lv.getByText('载入历史…')).toBeTruthy() }) - it('pending interactions render placeholder cards', () => { + it('renders approval cards while questions stay in the composer', () => { const h = makeHarness({ - pending: [{ kind: 'approval', rpcId: 'r1' as never, approvalId: 'ap1', toolName: 'bash' }], + pending: [ + { kind: 'approval', rpcId: 'r1' as never, approvalId: 'ap1', toolName: 'bash' }, + { + kind: 'question', rpcId: 'r2' as never, + questions: [{ id: 'mode', question: 'Composer only?', options: [{ label: 'Yes' }] }], + }, + ], }) const view = render() expect(view.getByText(/等待审批/)).toBeTruthy() + expect(view.queryByText('Composer only?')).toBeNull() }) }) diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index 2bdcb52902..bc829314eb 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -1,20 +1,18 @@ // @vitest-environment jsdom // Branch tails the acceptance specs do not reach: ToolRow stopped-state dot, -// PendingCard question arm, bash sample error pill, registry disposer +// Bash sample error pill and registry disposer // idempotence re-entry, register.ts explicit bashSampleScope override, the // node-half empty apply, and AssistantMarkdown reasoning/unknown block arms. import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import type { ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' -import type { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { UseSession } from '@deepseek-ai/dsh-client-web-react' import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ConversationService, Translate, ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as nodeApply } from '../src/index.ts' import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx' import { ToolRow } from '../src/client/chat/ToolRow.tsx' -import { PendingCard } from '../src/client/chat/PendingCard.tsx' import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' import { BashRow } from '../src/client/toolviews/bash-sample.tsx' import { registerChat } from '../src/client/chat/register.ts' @@ -34,13 +32,6 @@ describe('tails', () => { expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull() }) - it('PendingCard renders the question arm with its count', () => { - const view = render( - , - ) - expect(view.getByText(/等待回答(2 题)/)).toBeTruthy() - }) - it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => { const view = render( opts?.fallback ?? null, +} function snapshotBase(): ConversationSnapshot { return { @@ -55,6 +58,7 @@ describe('ConversationRoot branches', () => { composer={{ useDraft: () => '', setDraft: vi.fn(), send: vi.fn(), stop: vi.fn() }} actions={{ openView: vi.fn(), open }} renderView={() =>
} + slots={fallbackSlots} />, ) return { view, open } @@ -99,6 +103,7 @@ describe('ConversationRoot branches', () => { composer={{ useDraft: () => '', setDraft: vi.fn(), send: vi.fn(), stop: vi.fn() }} actions={{ openView: vi.fn(), open: vi.fn() }} renderView={(entry) =>
} + slots={fallbackSlots} />, ) expect(view.getByTestId('body-chat')).toBeTruthy() diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index ab23a64389..cf172353f4 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -11,11 +11,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { FC } from 'react' import { bindSnapshotSelector, createSnapshotStore } from '@deepseek-ai/dsh-client-web-react' import type { UseSession } from '@deepseek-ai/dsh-client-web-react' -import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' +import type { PendingInteraction, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' import { ConversationRoot, DetailsPanel, EmptyState, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { SelectionTarget, ViewEntry, ViewId } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ConversationInjected, SelectionTarget, ViewEntry, ViewId } from '@deepseek-ai/dsh-client-ui-conversation/client' const sid = (s: string): SessionId => s as SessionId @@ -28,11 +28,12 @@ interface FakeSnapshot { running: boolean removed: boolean promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null + pending: readonly PendingInteraction[] } function fakeSession(init: Partial = {}) { const store = createSnapshotStore({ - nodes: [], runningCalls: [], running: false, removed: false, promptError: null, ...init, + nodes: [], runningCalls: [], running: false, removed: false, promptError: null, pending: [], ...init, }) return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession } } @@ -67,8 +68,11 @@ describe('EmptyState', () => { }) describe('ConversationRoot', () => { - function bench(views: ViewEntry[], active?: string) { - const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }] }) + function bench( + views: ViewEntry[], active?: string, init: Partial = {}, + renderSlot?: ConversationInjected['slots']['renderSlot'], + ) { + const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }], ...init }) const activeStore = createSnapshotStore(active) const openView = vi.fn((v: string) => { activeStore.set(v) }) const open = vi.fn() @@ -98,6 +102,7 @@ describe('ConversationRoot', () => { }} actions={{ openView: openView as (v: never) => void, open }} renderView={(entry) => { rendered.push(entry.id); return
}} + slots={{ renderSlot: renderSlot ?? ((_key, _props, opts) => opts?.fallback ?? null) } as ConversationInjected['slots']} />) return { ui, openView, open, rendered, send, drafts } } @@ -133,6 +138,23 @@ describe('ConversationRoot', () => { fireEvent.keyDown(box, { key: 'Enter' }) expect(send).toHaveBeenCalledWith('queue') }) + + it('dispatches a pending question to the composer slot instead of rendering InputBar', () => { + const renderSlot = vi.fn(() =>
question takeover
) as unknown as ConversationInjected['slots']['renderSlot'] + bench([view('chat', 'Chat')], undefined, { + pending: [{ + kind: 'question', rpcId: 'rq' as never, + questions: [{ id: 'mode', question: 'Choose?', options: [{ label: 'Fast' }] }], + }], + }, renderSlot) + expect(screen.getByText('question takeover')).toBeTruthy() + expect(screen.queryByPlaceholderText(/输入消息/)).toBeNull() + expect(renderSlot).toHaveBeenCalledWith( + 'conversation.composer', + expect.objectContaining({ interaction: expect.objectContaining({ rpcId: 'rq' }) }), + expect.objectContaining({ entryKey: 'question' }), + ) + }) }) describe('DetailsPanel', () => { diff --git a/packages/client/ui-question/README.md b/packages/client/ui-question/README.md new file mode 100644 index 0000000000..a02c85d54c --- /dev/null +++ b/packages/client/ui-question/README.md @@ -0,0 +1,20 @@ +# @deepseek-ai/dsh-client-ui-question + +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. 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`. + +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. + +## 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. diff --git a/packages/client/ui-question/package.json b/packages/client/ui-question/package.json new file mode 100644 index 0000000000..d144ecf1d1 --- /dev/null +++ b/packages/client/ui-question/package.json @@ -0,0 +1,66 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-question", + "description": "Web ask_user_question feature: host tool mount plus composer-takeover question UI", + "version": "0.0.1", + "private": true, + "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" + }, + "dshClient": { + "inject": [ + "@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-tool-ask-user": "workspace:^", + "clsx": "^2.0.0", + "react": "^18.2.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-user-interaction": "workspace:^", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ] +} diff --git a/packages/client/ui-question/src/client/QuestionComposer.module.css b/packages/client/ui-question/src/client/QuestionComposer.module.css new file mode 100644 index 0000000000..21cd664ee3 --- /dev/null +++ b/packages/client/ui-question/src/client/QuestionComposer.module.css @@ -0,0 +1,329 @@ +.frame { + display: flex; + justify-content: center; + padding: 6px 24px 10px; +} + +.card { + width: 100%; + max-width: 720px; + padding: 14px 16px 12px; + border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 18px; + background: var(--dsw-specific-input-major); + box-shadow: var(--dsw-shadow-lv1-blur); + color: var(--dsw-alias-label-primary); +} + +.card, +.card * { + box-sizing: border-box; +} + +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 8px; +} + +.headingBlock { + min-width: 0; + padding: 1px 2px; +} + +.eyebrow { + margin-bottom: 2px; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 16px; +} + +.title { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 6px; + margin: 0; + font-size: 16px; + line-height: 22px; + font-weight: 600; +} + +.multiSelectHint { + color: var(--dsw-alias-label-tertiary); + font-size: 14px; + line-height: 20px; + font-weight: 400; + white-space: nowrap; +} + +.headerActions, +.footerActions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.progress { + padding: 0 6px; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 24px; + white-space: nowrap; +} + +.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; +} + +.options { + display: flex; + flex-direction: column; + gap: 4px; +} + +.option { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + min-height: 42px; + padding: 5px 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, +.customTrigger:disabled { + cursor: default; +} + +.number { + display: grid; + place-items: center; + flex: 0 0 28px; + width: 28px; + height: 28px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 999px; + background: var(--dsw-alias-bg-module-platform); + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; +} + +.optionCopy { + min-width: 0; + flex: 1; +} + +.optionLine { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 2px 6px; +} + +.optionLabel { + font-size: 14px; + line-height: 20px; + font-weight: 600; +} + +.badge { + padding: 0 6px; + border-radius: 999px; + background: var(--dsw-alias-bg-module-platform); + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 18px; +} + +.description { + color: var(--dsw-alias-label-tertiary); + font-size: 13px; + line-height: 20px; + font-weight: 400; +} + +.choiceIcon { + display: grid; + place-items: center; + width: 20px; + color: var(--dsw-alias-label-tertiary); +} + +.custom { + border: 1px solid transparent; + border-radius: 12px; +} + +.customOpen { + border-color: var(--dsw-alias-border-l2); + background: var(--dsw-alias-bg-module-platform); +} + +.customOptionless { + border: none; + background: transparent; +} + +.customTrigger { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + min-height: 42px; + padding: 5px 8px; + border: none; + background: transparent; + color: var(--dsw-alias-label-tertiary); + font-size: 14px; + line-height: 20px; + text-align: left; + cursor: pointer; +} + +.customTrigger:hover:not(:disabled) { + color: var(--dsw-alias-label-primary); +} + +.customInput { + display: block; + width: calc(100% - 20px); + min-height: 54px; + max-height: 140px; + margin: 0 10px 10px; + padding: 7px 10px; + resize: vertical; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 10px; + outline: none; + background: var(--dsw-specific-input-major); + color: var(--dsw-alias-label-primary); + caret-color: var(--dsw-alias-state-business-primary); + font: inherit; + font-size: 13px; + line-height: 20px; +} + +.customInput:focus { + border-color: var(--dsw-alias-state-business-primary); +} + +.customInput::placeholder { + color: var(--dsw-alias-label-caption); +} + +.customOptionless .customInput { + width: 100%; + min-height: 58px; + margin: 0; + background: var(--dsw-alias-bg-module-platform); +} + +.footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 8px; + padding: 0 2px; +} + +.feedback { + min-height: 16px; + color: var(--dsw-alias-state-error-primary); + font-size: 11px; + line-height: 16px; +} + +@media (max-width: 720px) { + .frame { + padding: 6px 10px 10px; + } + + .card { + padding: 12px 10px 10px; + border-radius: 16px; + } + + .header { + display: block; + } + + .headerActions { + justify-content: flex-end; + margin-top: 8px; + } + + .headingBlock { + padding: 0 2px; + } + + .title { + font-size: 15px; + line-height: 21px; + } + + .option, + .customTrigger { + align-items: flex-start; + gap: 8px; + padding: 6px; + } + + .choiceIcon { + margin-top: 3px; + } + + .footer { + align-items: flex-end; + } + + .footerActions { + flex-shrink: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .option { + transition: none; + } +} diff --git a/packages/client/ui-question/src/client/QuestionComposer.tsx b/packages/client/ui-question/src/client/QuestionComposer.tsx new file mode 100644 index 0000000000..49f74ce7f7 --- /dev/null +++ b/packages/client/ui-question/src/client/QuestionComposer.tsx @@ -0,0 +1,308 @@ +import { useState, type KeyboardEvent } from 'react' +import clsx from 'clsx' +import type { QuestionResponsePayload } from '@deepseek-ai/dsh-client-connection/client' +import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client' +import { + Button, IconCheckOutline16, IconChevronLeftOutline14, IconChevronRightOutline14, + IconCloseOutline16, IconEditOutline16, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { QuestionComposerOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import css from './QuestionComposer.module.css' + +type QuestionInteraction = Extract +type Answer = QuestionResponsePayload['answer'] + +interface DraftAnswer { + selected: string[] + custom: string + customOpen: boolean + skipped: boolean +} + +/** Actions assembled from the session object layer. */ +export interface QuestionComposerInjected { + actions: { + answer(interaction: QuestionInteraction, answer: Answer): Promise + cancel(interaction: QuestionInteraction): Promise + } +} + +/** Full question-composer props. */ +export type QuestionComposerProps = QuestionComposerOwnerProps & QuestionComposerInjected + +/** + * 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 } +} + +/** + * Remove a conventional multi-select suffix so the hint can be styled separately. + * @param title - Question title supplied by the interaction request. + * @returns Question title without a trailing multi-select marker. + */ +export function parseQuestionTitle(title: string): string { + return title.replace(/\s*[((]可多选[))]\s*$/, '') +} + +/** Return whether a textarea key event belongs to an active IME composition. */ +function isComposing(event: KeyboardEvent): boolean { + return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229 +} + +/** + * Composer takeover boundary; rpcId keys local drafts while same-id replay preserves them. + * @param props - Pending interaction and scoped answer/cancel actions. + * @returns The question flow for this request. + */ +export function QuestionComposer(props: QuestionComposerProps) { + return +} + +function QuestionFlow({ interaction, actions }: QuestionComposerProps) { + const questions = interaction.questions + const [index, setIndex] = useState(0) + const [drafts, setDrafts] = useState(() => questions.map(question => ({ + selected: [], custom: '', customOpen: (question.options?.length ?? 0) === 0, skipped: false, + }))) + const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null) + const [error, setError] = useState(null) + const question = questions[index]! + const draft = drafts[index]! + const hasOptions = (question.options?.length ?? 0) > 0 + + const cancelFlow = (): void => { + setBusy('cancel') + setError(null) + void actions.cancel(interaction).catch((cause: unknown) => { + setBusy(null) + setError(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) => { + const selected = question.multiSelect === true + ? current.selected.includes(label) + ? current.selected.filter(item => item !== label) + : [...current.selected, label] + : [label] + return { selected, custom: '', customOpen: false, skipped: false } + }) + if (question.multiSelect !== true && index < questions.length - 1) { + setIndex(current => current + 1) + } + } + + const openCustom = (): void => { + updateDraft(current => ({ ...current, selected: [], customOpen: true, skipped: false })) + } + + 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('请先完成这道问题。') + return + } + const answer: Answer = { + 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 === '' ? value.selected : [], + ...(custom === '' ? {} : { custom }), + } + }), + } + setBusy('answer') + setError(null) + void actions.answer(interaction, answer).catch((cause: unknown) => { + setBusy(null) + setError(cause instanceof Error ? cause.message : String(cause)) + }) + } + + const continueFlow = (): void => { + if (!answered(draft)) { + setError('请选择一个选项或填写自定义答案。') + return + } + if (index < questions.length - 1) { + setIndex(current => current + 1) + setError(null) + return + } + submitDrafts(drafts) + } + + const skipQuestion = (): void => { + const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index + ? { + selected: [], custom: '', + customOpen: (question.options?.length ?? 0) === 0, + skipped: true, + } + : item) + setDrafts(nextDrafts) + setError(null) + if (index < questions.length - 1) { + setIndex(current => current + 1) + return + } + submitDrafts(nextDrafts) + } + + return ( +
+
+
+
+ {question.header !== undefined &&
{question.header}
} +

+ {question.multiSelect === true + ? parseQuestionTitle(question.question) + : question.question} + {question.multiSelect === true && 可多选} +

+
+
+ {index + 1} / {questions.length} + + + +
+
+ +
+ {(question.options ?? []).map((option, optionIndex) => { + const selected = draft.selected.includes(option.label) + const display = parseRecommendedLabel(option.label) + return ( + + ) + })} + +
+ {hasOptions && ( + + )} + {draft.customOpen && ( +