feat(web): answerable ask_user_question flow with toolview verdict row

The pending question now owns exactly two surfaces: the redesigned
QuestionComposer takeover (footer pager, checkbox multi-select,
always-visible custom input, locale-injected bilingual chrome) collects
the answers, and a dedicated ask_user_question toolview row reports the
interaction outcome — waiting, N/M answered, cancelled (ASK_CANCELLED),
or interrupted with stopped semantics (ASK_ABORTED). PendingCard narrows
to approval waits only. Toolview leading icons and the hover chevron
unify on the tertiary label color, the checklist glyph matches the
14px figma extract, and dev-watch registers CSS modules so css-only
edits rebuild.
This commit is contained in:
Yif
2026-07-29 14:12:01 +08:00
parent f63d2deecf
commit 7639f4cb68
32 changed files with 869 additions and 450 deletions

View File

@@ -124,9 +124,12 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
const abs = importer !== undefined ? resolvePath(dirname(importer), source) : source
return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
},
async load(virtualId: string) {
async load(this: { addWatchFile?: (id: string) => void }, virtualId: string) {
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
// Virtual modules hide the real file from the watcher; register it so
// dev-web rebuilds on a css-only edit.
this.addWatchFile?.(fileId)
const source = await readFile(fileId)
const { code, exports: cssExports } = transform({
filename: fileId,

View File

@@ -14,6 +14,7 @@ import { InputBar } from './skeleton/InputBar.tsx'
import { ChatView } from './chat/ChatView.tsx'
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
import { todoToolview } from './toolviews/todo-row.tsx'
import { askQuestionToolview } from './toolviews/ask-question-row.tsx'
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
import { queueDockEntry } from './queue/QueueDock.tsx'
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
@@ -187,6 +188,9 @@ export function apply(ctx: Context): void {
// The todo_write row rides the same seam (a product registration, not a sample).
ctx.plugin(todoToolview)
// The ask_user_question row: waiting/answered/cancelled interaction outcome.
ctx.plugin(askQuestionToolview)
// The plan strip rides the input dock above the queue rows (same posture).
ctx.plugin(todoDockEntry)

View File

@@ -361,7 +361,10 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
))}
</div>
)}
{pending.map(item => <PendingCard key={item.key} item={item} />)}
{/* Approval waits only: a pending question already shows as the
ask_user_question row (waiting state) plus the composer takeover. */}
{pending.filter(item => item.kind === 'approval')
.map(item => <PendingCard key={item.key} item={item} />)}
{/* Turn-level loading signal: rides the whole running turn (first-token
wait, tool execution, streaming) so it never flickers per step. */}
{running && <TurnDots />}

View File

@@ -1,31 +1,22 @@
// 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 (visible, not answerable — the
// composer-takeover approval panel is a P-II item; wire pending semantics
// already exist so the flow must show them). Question waits render through
// the ask_user_question toolview row + the composer takeover instead.
import { memo } from 'react'
import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import css from './PendingCard.module.css'
export interface PendingCardProps {
item: PendingInteraction
item: PendingWait<'approval'>
}
export const PendingCard = memo(function PendingCard({ item }: PendingCardProps) {
return (
<div className={css.card}>
{item.kind === 'approval' ? (
<>
<div className={css.title}><span className={css.mono}>{item.payload.toolName}</span></div>
{item.payload.reason !== undefined && <div className={css.reason}>{item.payload.reason}</div>}
</>
) : (
<>
<div className={css.title}>{item.payload.questions.length} </div>
<JsonBlock label="问题内容" payload={item.payload.questions} />
</>
)}
<div className={css.hint}>web </div>
<div className={css.title}><span className={css.mono}>{item.payload.toolName}</span></div>
{item.payload.reason !== undefined && <div className={css.reason}>{item.payload.reason}</div>}
<div className={css.hint}>web </div>
</div>
)
})

View File

@@ -62,12 +62,6 @@
color: var(--dsw-alias-label-tertiary);
}
/* The others-variant sparkle glyph is one gray step darker than the icon
family in the source design. */
.root[data-variant='others'] .leading {
color: var(--dsw-alias-label-secondary);
}
/* Cordis lifecycle tools retain their generic row mechanics while carrying a
shared product accent and tool-owned action title. */
.root[data-tool^='cordis_'] .leading,
@@ -87,10 +81,6 @@ button.leading {
cursor: pointer;
}
.chevron {
color: var(--dsw-alias-label-secondary);
}
/* Hover preview on expandable rows: the idle tool icon crossfades (100ms)
into a down chevron before the row is opened. The chevron overlays the
icon cell absolutely so both can stay mounted for the opacity transition. */

View File

@@ -7,7 +7,6 @@
// expandable content, retiring the details-panel handoff where feasible.
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { CodeBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
@@ -74,12 +73,12 @@ export function ToolRow({
? (
<>
<span className={css.iconIdle}>{icon}</span>
<IconChevronDownOutline14 className={clsx(css.chevron, css.chevronHover)} />
<IconChevronDownOutline14 className={css.chevronHover} />
</>
)
: icon
const leading = open
? <IconChevronDownOutline14 className={css.chevron} />
? <IconChevronDownOutline14 />
: leadingFor(state, collapsedIcon)
return (
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>

View File

@@ -0,0 +1,94 @@
// ask_user_question toolview: question-flavored summary row replacing the
// generic "Tool call" card, registered into the keyed
// 'conversation.chat.toolview' hole like todo-row. The row composes ToolRow
// (chrome, running sweep, leading expansion) and swaps in the interaction
// outcome — `waiting` while pending, answered-count once settled, `cancelled`
// when the user dismissed the whole set — because the questions themselves
// render in the composer takeover.
import { IconQuestionOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from 'cordis'
import type { ToolRowProps } from '../contract/slots.ts'
import { toolRowModel } from '../contract/tool-call-model.ts'
import { ToolRow } from '../chat/ToolRow.tsx'
/** One parsed answer entry, shape-checked (result JSON crosses the wire). */
interface AnswerEntry { selected?: unknown; custom?: unknown }
function isAnswer(value: unknown): value is AnswerEntry {
return typeof value === 'object' && value !== null
}
/** `${answered}/${total} answered` off the result JSON (a skipped question has
* empty `selected` and no `custom`); null on unexpected shape (generic fallback). */
function answeredSummary(text: string): string | null {
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch {
return null
}
if (typeof parsed !== 'object' || parsed === null) return null
const answers = (parsed as { answers?: unknown }).answers
if (!Array.isArray(answers) || !answers.every(isAnswer)) return null
const answered = answers.filter(a =>
(Array.isArray(a.selected) && a.selected.length > 0)
|| (typeof a.custom === 'string' && a.custom !== '')).length
return `${answered}/${answers.length} answered`
}
/** One-line question-interaction row (row click opens details; leading toggle
* expands the raw args). */
export function AskQuestionRow({ toolName, block, openDetails }: ToolRowProps) {
const model = toolRowModel(toolName, block)
// Composer verdicts settle the call as specific UserInteractionErrors
// (apiproxy ask_user_question handler): 'ASK_CANCELLED' is the user's own
// dismissal of the set, 'ASK_ABORTED' is a turn interrupt landing while the
// question was pending. Both name their verdict instead of the generic
// failed shape, and the abort keeps the shared stopped (amber) semantics of
// any other interrupted tool call.
const code = 'kind' in block ? block.error?.code : undefined
let summary = model.summary
let state = model.state
if (code === 'ASK_CANCELLED') {
summary = 'cancelled'
} else if (code === 'ASK_ABORTED') {
summary = 'interrupted'
state = 'stopped'
} else if (model.state === 'running') {
summary = 'waiting'
} else if ('kind' in block && model.state === 'ok') {
const text = block.content.filter(b => b.type === 'text').map(b => b.text).join('')
summary = answeredSummary(text) ?? model.summary
}
return (
<ToolRow
variant={model.variant}
toolName={toolName}
icon={<IconQuestionOutline14 />}
title="Ask question"
summary={summary}
body={model.body}
state={state}
onOpenDetails={openDetails}
/>
)
}
/**
* The ask-question row as a plain registrant plugin, riding the same
* load-order seam as todo-toolview: `inject: ['conversation']` guarantees the
* chat entry (and with it the 'conversation.chat.toolview' declaration) is on
* the ledger.
*/
export const askQuestionToolview = {
name: 'ask-question-toolview',
inject: ['slots', 'conversation'],
/**
* Register the ask-question row into the chat view's keyed toolview hole.
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'ask_user_question' }, AskQuestionRow)
},
}

View File

@@ -1,58 +0,0 @@
/* todo_write plan-update row: ToolRow chrome (figma 780:53675) —
[16 checklist] gap6 [title 14/24] gap8 [2x2 dot] gap8 [summary FILL truncate]. */
.row {
display: flex;
align-items: center;
height: 24px;
min-width: 0;
cursor: pointer;
border-radius: 6px;
}
.leading {
flex: none;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
color: var(--dsw-alias-label-tertiary);
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
font-weight: 500; /* figma wt510, rendered 500 */
color: var(--dsw-alias-label-primary-dimmed);
}
.sep {
flex: none;
width: 2px;
height: 2px;
border-radius: 1px;
margin: 0 8px;
background: var(--dsw-alias-label-caption);
}
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
}
.err {
flex: none;
margin-left: 8px;
color: var(--dsw-alias-state-error-primary);
font-size: 11px;
line-height: 16px;
}

View File

@@ -1,16 +1,16 @@
// todo_write toolview: plan-flavored summary row replacing the generic
// "Tool call" card, registered into the keyed 'conversation.chat.toolview'
// hole like the bash sample (a product registration, not a sample). The row
// summarizes the written list (counts + active item) from the call args; the
// composes ToolRow (chrome, running sweep, leading expansion) and swaps in a
// summary of the written list (counts + active item) from the call args; the
// durable list itself renders in the TodoPanel above the composer, so the
// row stays one line. Chrome matches ToolRow (figma 780:53675).
// row stays one line.
import type { KeyboardEvent } from 'react'
import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from 'cordis'
import { IconChecklistOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowProps } from '../contract/slots.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
import css from './todo-row.module.css'
import { toolRowModel } from '../contract/tool-call-model.ts'
import { ToolRow } from '../chat/ToolRow.tsx'
/** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */
interface TodoWriteItem { content?: unknown; status?: unknown }
@@ -40,48 +40,25 @@ function summarize(argsRaw: string): string | null {
: head
}
/** Leading-slot state substitution matches ToolRow / bash: icon yields to the
* state semantic while running or failed; ok keeps the checklist glyph. */
function leadingFor(state: ToolRowState) {
switch (state) {
case 'running': return <StateDot state="ongoing" />
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
default: return <IconChecklistOutline16 />
}
}
/** One-line plan update row (click opens the raw args in details). Non-ok
* execution states keep the generic row's dot semantics — a cancelled call
* wrote no todo/write, so it must not read as a completed update. */
/** One-line plan update row (row click opens details; leading toggle expands
* the raw args). Non-ok execution states keep the shared row's dot semantics
* — a cancelled call wrote no todo/write, so it must not read as a completed
* update. */
export function TodoRow({ toolName, block, openDetails }: ToolRowProps) {
const model = toolRowModel(toolName, block)
const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
const summary = summarize(argsRaw) ?? model.summary
// Button semantics, not a <button>: the row carries inline spans a button
// would flatten, and ToolRow takes the same role/tabIndex/Enter-Space route.
const openFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
openDetails()
}
return (
<div
className={css.row}
data-sample="todo-row"
data-state={model.state}
role="button"
tabIndex={0}
onClick={openDetails}
onKeyDown={openFromKeyboard}
>
<span className={css.leading} aria-hidden>{leadingFor(model.state)}</span>
<span className={css.title}></span>
<span className={css.sep} aria-hidden />
<span className={css.summary}>{summary}</span>
{model.state === 'error' && <span className={css.err}>failed</span>}
{model.state === 'stopped' && <span className={css.err}></span>}
</div>
<ToolRow
variant={model.variant}
toolName={toolName}
icon={<IconChecklistOutline14 />}
title="更新任务清单"
summary={summary}
body={model.body}
state={model.state}
onOpenDetails={openDetails}
/>
)
}

View File

@@ -0,0 +1,130 @@
// @vitest-environment jsdom
/**
* ask_user_question toolview acceptance: `waiting` summary while running,
* answered-count from the result JSON once settled (skipped answers
* excluded), the cancelled/interrupted verdicts off ASK_CANCELLED and
* ASK_ABORTED, shared ToolRow state
* semantics for interrupted/failed calls, and generic fallbacks on
* malformed results.
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Export discipline: packages/client/AGENTS.md.
import { AskQuestionRow, askQuestionToolview } from '../src/client/toolviews/ask-question-row.tsx'
afterEach(cleanup)
const ARGS = JSON.stringify({ questions: [{ id: 'a' }, { id: 'b' }, { id: 'c' }] })
const resultNode = (argsRaw: string, resultText: string | null, over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
call: { name: 'ask_user_question', argsRaw },
content: resultText === null ? [] : [{ type: 'text', text: resultText }],
isError: false, callView: null, resultView: null, ...over,
})
const runningCall = (argsRaw: string) =>
({ callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, callView: null })
function rowProps(block: unknown, openDetails = vi.fn()): ToolRowProps {
return {
callId: 'c1', toolName: 'ask_user_question', block,
openDetails,
sessionId: 's1',
useSessions: () => undefined,
} as unknown as ToolRowProps
}
const answers = (entries: unknown[]): string => JSON.stringify({ answers: entries })
describe('AskQuestionRow', () => {
it('running call reads waiting (args-independent: the composer takeover shows the questions)', () => {
const view = render(<AskQuestionRow {...rowProps(runningCall(ARGS))} />)
expect(screen.getByText('Ask question')).toBeTruthy()
expect(screen.getByText('waiting')).toBeTruthy()
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
})
it('settled result counts answered entries (selected choices or custom text)', () => {
render(<AskQuestionRow {...rowProps(resultNode(ARGS, answers([
{ id: 'a', selected: ['x'] },
{ id: 'b', selected: [], custom: 'freeform' },
{ id: 'c', selected: ['y', 'z'], custom: '' },
])))} />)
expect(screen.getByText('3/3 answered')).toBeTruthy()
})
it('skipped questions (no selection, no custom) stay out of the answered count', () => {
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, answers([
{ id: 'a', selected: ['x'] },
{ id: 'b', selected: [], custom: '' },
{ id: 'c' },
])))} />)
expect(screen.getByText('1/3 answered')).toBeTruthy()
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
it.each([
{ label: 'non-JSON result text', text: 'oops' },
{ label: 'non-object result root', text: '"str"' },
{ label: 'null result root', text: 'null' },
{ label: 'missing answers array', text: '{"other":1}' },
{ label: 'null answer entries', text: '{"answers":[null]}' },
{ label: 'empty result content', text: null },
])('settled result falls back to the generic summary on $label', ({ text }) => {
render(<AskQuestionRow {...rowProps(resultNode(ARGS, text))} />)
expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy()
})
it('user cancellation names the verdict instead of the generic failed shape', () => {
// ASK_CANCELLED: the apiproxy ask_user_question handler's cancel error.
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null,
{ isError: true, error: { name: 'UserInteractionError', code: 'ASK_CANCELLED' } }))} />)
expect(screen.getByText('cancelled')).toBeTruthy()
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
})
it('a turn abort while pending reads interrupted with stopped semantics', () => {
// ASK_ABORTED: the apiproxy ask handler's turn-abort settlement.
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null,
{ isError: true, error: { name: 'UserInteractionError', code: 'ASK_ABORTED' } }))} />)
expect(screen.getByText('interrupted')).toBeTruthy()
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
})
it('an interrupted turn reads as stopped, not cancelled', () => {
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null,
{ isError: true, error: { name: 'Interrupted', code: 'interrupted' } }))} />)
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
expect(screen.queryByText('cancelled')).toBeNull()
expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy()
})
it('other tool errors keep the generic summary with the error state', () => {
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null, { isError: true }))} />)
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy()
})
it('window-truncated result (call head lost) falls back to the callId summary', () => {
render(<AskQuestionRow {...rowProps(resultNode('', null, { call: null }))} />)
expect(screen.getByText('ask_user_question · c1')).toBeTruthy()
})
it('row click opens details', () => {
const openDetails = vi.fn()
render(<AskQuestionRow {...rowProps(resultNode(ARGS, answers([])), openDetails)} />)
fireEvent.click(screen.getByText('Ask question'))
expect(openDetails).toHaveBeenCalledTimes(1)
})
it('askQuestionToolview is a plain registrant riding the conversation load-order seam', () => {
expect(askQuestionToolview.name).toBe('ask-question-toolview')
expect(askQuestionToolview.inject).toEqual(['slots', 'conversation'])
const register = vi.fn()
askQuestionToolview.apply({ slots: { register } } as never)
expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'ask_user_question' }, AskQuestionRow)
})
})

View File

@@ -112,13 +112,13 @@ describe('apply wiring', () => {
expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' })
})
it('mounts the bash sample and the todo row as keyed entries through the load-order seam', async () => {
it('mounts the bash sample and the product rows as keyed entries through the load-order seam', async () => {
const b = await bench()
await b.fiber.await()
// Both registrant plugins' inject: ['slots', 'conversation'] resolved — the
// Every registrant plugin's inject: ['slots', 'conversation'] resolved — the
// service being present implies the chat entry declared the hole first.
const entries = b.slots.entries('conversation.chat.toolview')
expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write'])
expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write', 'ask_user_question'])
})
it('plugin fiber disposal collects every registration (unload cascade, ring and hole included)', async () => {

View File

@@ -1,20 +1,17 @@
// @vitest-environment jsdom
// Branch tails the acceptance specs do not reach: ToolRow stopped-state dot,
// PendingCard question arm, bash sample state dots, the node-half empty
// apply, and AssistantMarkdown reasoning/unknown block arms.
// bash sample state dots, 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 { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { RunningToolCall, SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { ToolRowOwnerProps, ToolRowProps } 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'
@@ -33,13 +30,6 @@ describe('tails', () => {
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
})
it('PendingCard renders the question arm with its count', () => {
const view = render(
<PendingCard item={new PendingWait('question', RpcId('r1'), 's1' as SessionId, { questions: [{}, {}] } as PendingWait<'question'>['payload'], vi.fn())} />,
)
expect(view.getByText(/等待回答2 题)/)).toBeTruthy()
})
it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => {
const view = render(
<AssistantMarkdown

View File

@@ -3,8 +3,8 @@
* Todo display acceptance: the TodoPanel plan strip (empty-hidden, status
* rows, collapse), its TodoDock adapter (selects the plan off the session
* snapshot and follows changes), and the todo_write toolview row (progress
* summary from args, generic fallback on malformed JSON, error badge,
* keyboard activation).
* summary from args, generic fallback on malformed JSON, shared ToolRow
* state dots and leading expansion).
*/
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -120,8 +120,8 @@ describe('TodoRow', () => {
expect(screen.getByText('1/1 已完成')).toBeTruthy()
})
it('keeps the non-ok execution states visible: running dot, interrupted marker', () => {
// A running call (no result yet) shows the ongoing dot, never the ok badge.
it('keeps the non-ok execution states visible through the shared row states', () => {
// A running call (no result yet) carries the running state (row sweep).
const args = JSON.stringify({ todos: LIST })
const running = render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />)
expect(running.container.querySelector('[data-state="running"]')).not.toBeNull()
@@ -130,12 +130,11 @@ describe('TodoRow', () => {
// A cancelled call wrote no todo/write: the row must not read as a completed update.
const stopped = render(<TodoRow {...rowProps(resultNode(args, { isError: true, error: { name: 'Interrupted', code: 'interrupted' } }))} />)
expect(stopped.container.querySelector('[data-state="stopped"]')).not.toBeNull()
expect(stopped.getByText('已中断')).toBeTruthy()
})
it('falls back to the generic summary on malformed args and flags errors', () => {
render(<TodoRow {...rowProps(resultNode('not json', { isError: true }))} />)
expect(screen.getByText('failed')).toBeTruthy()
it('falls back to the generic summary on malformed args and marks the error state', () => {
const view = render(<TodoRow {...rowProps(resultNode('not json', { isError: true }))} />)
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
// Generic others summary: "<tool> · <raw>".
expect(screen.getByText('todo_write · not json')).toBeTruthy()
})
@@ -148,19 +147,14 @@ describe('TodoRow', () => {
expect(openDetails).toHaveBeenCalledTimes(1)
})
it('opens details from the keyboard on Enter and Space, ignoring other keys', () => {
it('leading toggle expands the raw args body without opening details', () => {
const openDetails = vi.fn()
render(<TodoRow {...rowProps(resultNode(ARGS), openDetails)} />)
const row = screen.getByRole('button')
expect(row.getAttribute('tabindex')).toBe('0')
fireEvent.keyDown(row, { key: 'Enter' })
fireEvent.keyDown(row, { key: ' ' })
expect(openDetails).toHaveBeenCalledTimes(2)
// Space must not also scroll the flow: the handler claims the event.
expect(fireEvent.keyDown(row, { key: ' ' })).toBe(false)
fireEvent.keyDown(row, { key: 'a' })
fireEvent.keyDown(row, { key: 'ArrowDown' })
expect(openDetails).toHaveBeenCalledTimes(3)
fireEvent.click(screen.getByRole('button', { expanded: false }))
expect(screen.getByRole('button', { expanded: true })).toBeTruthy()
// The expanded body is the pretty-printed args, not the tool output.
expect(screen.getByText(/搭骨架/)).toBeTruthy()
expect(openDetails).not.toHaveBeenCalled()
})
it.each([

View File

@@ -123,6 +123,16 @@ export const IconCheckOutline16 = ({ size = 16, className }: IconProps) => (
</svg>
)
/** ic_ds_check_outline_14 */
export const IconCheckOutline14 = ({ size = 14, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M11.5635 4.58984L7.61426 9.07715C7.35154 9.37561 7.11346 9.64812 6.89453 9.84668C6.66593 10.054 6.38519 10.2506 6.01465 10.3164C5.82079 10.3508 5.62207 10.3529 5.42773 10.3213C5.0561 10.2609 4.77266 10.0674 4.54102 9.86328C4.31926 9.66791 4.07752 9.39911 3.81055 9.10449L2.44531 7.59863L3.55664 6.59082L4.92188 8.09766C5.21256 8.41844 5.38878 8.61191 5.53223 8.73828C5.61022 8.80699 5.65253 8.83192 5.66895 8.83984C5.69648 8.84429 5.72449 8.84467 5.75195 8.83984C5.72657 8.84451 5.75564 8.85422 5.88672 8.73535C6.02833 8.60692 6.20225 8.41088 6.48828 8.08594L10.4385 3.59961L11.5635 4.58984Z"
fill="currentColor"
/>
</svg>
)
/** ic_ds_branch_outline_16 */
export const IconBranchOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -653,13 +663,13 @@ export const IconDataOutline16 = ({ size = 16, className }: IconProps) => (
</svg>
)
/** ic_checklist_outline_16 (figma extract): two rings + two list bars. */
export const IconChecklistOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path transform="translate(1.736 2.0752)" d="M12.5279 8.64648V9.92617H6.48105V8.64648H12.5279Z" fill="currentColor" />
<path transform="translate(1.736 2.0752)" d="M12.5279 1.92275V3.20244H6.48105V1.92275H12.5279Z" fill="currentColor" />
<path transform="translate(1.736 2.0752)" d="M3.84531 9.28623C3.84525 8.57774 3.271 8.00342 2.5625 8.00342C1.85405 8.00348 1.27975 8.57778 1.27969 9.28623C1.27969 9.99474 1.85401 10.569 2.5625 10.569C3.27105 10.569 3.84531 9.99478 3.84531 9.28623ZM5.12578 9.28623C5.12578 10.7017 3.97797 11.8495 2.5625 11.8495C1.14709 11.8494 0 10.7017 0 9.28623C6.59755e-05 7.87086 1.14713 6.7238 2.5625 6.72373C3.97793 6.72373 5.12572 7.87082 5.12578 9.28623Z" fill="currentColor" />
<path transform="translate(1.736 2.0752)" d="M3.84551 2.5625C3.84549 1.85402 3.27118 1.27969 2.5627 1.27969C1.85422 1.2797 1.2799 1.85403 1.27988 2.5625C1.27988 3.27098 1.85422 3.8453 2.5627 3.84531C3.27119 3.84531 3.84551 3.27099 3.84551 2.5625ZM5.1252 2.5625C5.1252 3.97792 3.97811 5.125 2.5627 5.125C1.14729 5.12499 0.000195313 3.97791 0.000195313 2.5625C0.000208508 1.1471 1.1473 1.31957e-05 2.5627 0C3.9781 0 5.12518 1.1471 5.1252 2.5625Z" fill="currentColor" />
/** ic_checklist_outline_14 (figma extract): two rings + two list bars. */
export const IconChecklistOutline14 = ({ size = 14, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.3277 9.69629V10.976H7.28086V9.69629H13.3277Z" fill="currentColor" />
<path d="M13.3277 2.97256V4.25225H7.28086V2.97256H13.3277Z" fill="currentColor" />
<path d="M4.64512 10.336C4.64505 9.62755 4.07081 9.05322 3.3623 9.05322C2.65386 9.05329 2.07956 9.62759 2.07949 10.336C2.07949 11.0445 2.65382 11.6188 3.3623 11.6188C4.07085 11.6188 4.64512 11.0446 4.64512 10.336ZM5.92559 10.336C5.92559 11.7515 4.77777 12.8993 3.3623 12.8993C1.94689 12.8993 0.799805 11.7515 0.799805 10.336C0.799871 8.92066 1.94693 7.7736 3.3623 7.77354C4.77773 7.77354 5.92552 8.92062 5.92559 10.336Z" fill="currentColor" />
<path d="M4.64531 3.6123C4.6453 2.90382 4.07098 2.32949 3.3625 2.32949C2.65403 2.32951 2.0797 2.90383 2.07969 3.6123C2.07969 4.32079 2.65402 4.8951 3.3625 4.89512C4.07099 4.89512 4.64531 4.3208 4.64531 3.6123ZM5.925 3.6123C5.925 5.02772 4.77792 6.1748 3.3625 6.1748C1.9471 6.17479 0.8 5.02771 0.8 3.6123C0.800013 2.19691 1.9471 1.04982 3.3625 1.0498C4.77791 1.0498 5.92499 2.1969 5.925 3.6123Z" fill="currentColor" />
</svg>
)
@@ -680,3 +690,18 @@ export const IconListPenOutline16 = ({ size = 16, className }: IconProps) => (
/>
</svg>
)
/** ic_ds_question_outline_14 (figma extract): ring + question glyph. */
export const IconQuestionOutline14 = ({ size = 14, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12.5757 7.00012C12.5757 3.92085 10.0794 1.42463 7.00012 1.42456C3.9208 1.42456 1.42456 3.9208 1.42456 7.00012C1.42463 10.0794 3.92085 12.5757 7.00012 12.5757C10.0793 12.5756 12.5756 10.0793 12.5757 7.00012ZM13.8002 7.00012C13.8001 10.7559 10.7559 13.8001 7.00012 13.8002C3.2443 13.8002 0.199291 10.7559 0.199219 7.00012C0.199219 3.24426 3.24426 0.199219 7.00012 0.199219C10.7559 0.199291 13.8002 3.2443 13.8002 7.00012Z"
fill="currentColor"
/>
<path
d="M6.18042 8.68184C6.18043 8.09153 6.32893 7.34655 6.92127 6.8481C7.28566 6.54148 7.76104 6.27318 8.0022 6.10811C8.28964 5.91137 8.42234 5.76562 8.48328 5.58944C8.57774 5.31609 8.53121 5.00904 8.34912 4.76741C8.17409 4.53522 7.83879 4.32222 7.28186 4.32222C5.99668 4.32225 5.46969 5.11832 5.46949 5.78939H4.24414C4.24436 4.39942 5.36327 3.09691 7.28186 3.09688C8.17773 3.09688 8.89489 3.45606 9.32752 4.02999C9.75287 4.59438 9.86938 5.32775 9.64026 5.99019C9.44847 6.5444 9.04722 6.87743 8.69434 7.11898C8.29506 7.39226 8.02318 7.52192 7.70996 7.78548C7.51943 7.94582 7.40577 8.24899 7.40577 8.68184V8.75533H6.18042V8.68184Z"
fill="currentColor"
/>
<path d="M7.39455 9.44026V10.8109H6.16921V9.44026H7.39455Z" fill="currentColor" />
</svg>
)

View File

@@ -14,8 +14,8 @@ const icons = Object.fromEntries(
const iconNames = Object.keys(icons)
describe('ic_ds_ icon set', () => {
it('exports the full P-I set (43 deepsuite + 13 figma extracts)', () => {
expect(iconNames.length).toBe(56)
it('exports the full P-I set (43 deepsuite + 15 figma extracts)', () => {
expect(iconNames.length).toBe(58)
})
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {

View File

@@ -1,6 +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
README.md: 28132d1d0643f5e8f658ab77de9017467da2d172
README.zh.md: c70e77fc90eb5b226ceabd8a1e7cc7ca6c011c40
# pnpm run verify-translation-pairing --write packages/client/ui-question/README.md
README.md: 4611fc340426098bc1aa12f17e580fd829f0c702
README.zh.md: 50e69a25175a948c6c026a35d9a549397f820be6

View File

@@ -8,6 +8,8 @@ The component renders one question at a time with progress navigation, single- a
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.

View File

@@ -8,6 +8,8 @@ Web `ask_user_question` 功能插件。只有选择 Web 功能时,其主机侧
选择状态只存在于以请求 rpcId 为 key 的组件本地。使用相同 id 回放时,只要组件仍挂载,就会保留草稿;主机发出的 `question/resolved` 则会移除编辑器。主机仍具有最终决定权HTTP 交付成功不会在本地移除待处理状态。
编辑器外框文案(翻页器、按钮、占位符、校验提示)是双语的:插件在 `dsh-client-locale``question` 命名空间下注册 zh/en 词典,并通过 inject face 把绑定的翻译函数和 locale 快照源交给该配置项,因此切换语言会重新渲染已挂载的编辑器。问题与选项文本来自模型并原样渲染;载体失败消息也不经翻译直接显示。
## 模型体验
通过 `dsh-tool-ask-user` 间接影响;该包拥有模型可见的工具 schema 和结构化结果。

View File

@@ -24,7 +24,8 @@
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-ui-conversation"
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-locale"
],
"platform": "web"
},
@@ -35,6 +36,7 @@
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",

View File

@@ -1,24 +1,29 @@
/* The takeover seats where the input card sits, so the frame mirrors the
InputBar geometry (side pad 32, card cap 800) to keep both edges flush. */
.frame {
display: flex;
justify-content: center;
padding: 6px 24px 10px;
padding: 6px 32px 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: 720px;
max-width: 800px;
/* 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: 14px 16px 12px;
padding: 0 0 10px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 18px;
border-radius: 20px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv1-blur);
box-shadow: var(--dsw-shadow-lv2);
color: var(--dsw-alias-label-primary);
overflow: hidden;
}
.card,
@@ -26,44 +31,34 @@
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;
margin-bottom: 8px;
padding: 20px 16px 0 24px;
}
.headingBlock {
min-width: 0;
padding: 1px 2px;
}
.eyebrow {
margin-bottom: 2px;
/* 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 {
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;
font-weight: 500;
}
.detail {
@@ -74,20 +69,29 @@
font-weight: 400;
}
.headerActions,
.footerActions {
display: flex;
align-items: center;
gap: 4px;
gap: 12px;
flex-shrink: 0;
}
.pager {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.progress {
padding: 0 6px;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
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 {
@@ -116,7 +120,9 @@
.options {
display: flex;
flex-direction: column;
gap: 4px;
gap: 1px;
margin: 8px 0 0;
padding: 4px 12px;
/* The scrollable region of the capped card (ChatView list pattern). */
min-height: 0;
overflow-y: auto;
@@ -125,15 +131,15 @@
.option {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
width: 100%;
min-height: 42px;
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: 5px 8px;
padding: 6px 12px 6px 8px;
border: 1px solid transparent;
border-radius: 12px;
background: transparent;
@@ -152,25 +158,63 @@
border-color: var(--dsw-alias-border-l2);
}
.option:disabled,
.customTrigger:disabled {
.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 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);
flex: 0 0 20px;
width: 20px;
height: 20px;
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;
}
.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;
@@ -185,103 +229,101 @@
.optionLabel {
font-size: 14px;
line-height: 20px;
font-weight: 600;
line-height: 24px;
font-weight: 500;
}
.badge {
padding: 0 6px;
border-radius: 999px;
background: var(--dsw-alias-bg-module-platform);
color: var(--dsw-alias-label-secondary);
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: 13px;
line-height: 20px;
font-size: 14px;
line-height: 24px;
font-weight: 400;
}
.choiceIcon {
display: grid;
place-items: center;
width: 20px;
color: var(--dsw-alias-label-tertiary);
}
.custom {
/* Same reason as .option: the custom block is scroll content, and shrinking
it pushes its trigger row (and the open textarea) past the footer. */
flex-shrink: 0;
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 {
/* 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;
align-items: center;
gap: 10px;
gap: 8px;
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;
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: 6px 12px 6px 8px;
border: 1px solid transparent;
border-radius: 12px;
transition: background-color 120ms ease, border-color 120ms ease;
}
.customTrigger:hover:not(:disabled) {
color: var(--dsw-alias-label-primary);
.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 {
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;
flex: 1;
min-width: 0;
padding: 0;
border: none;
outline: none;
background: var(--dsw-specific-input-major);
background: transparent;
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);
font-size: 14px;
line-height: 24px;
}
.customInput::placeholder {
color: var(--dsw-alias-label-caption);
}
.customOptionless .customInput {
width: 100%;
min-height: 58px;
margin: 0;
/* 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 {
@@ -290,15 +332,17 @@
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
margin-top: 8px;
padding: 0 2px;
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) {
@@ -307,21 +351,15 @@
}
.card {
padding: 12px 10px 10px;
border-radius: 16px;
}
.header {
display: block;
padding: 10px 12px 0 18px;
}
.headerActions {
justify-content: flex-end;
margin-top: 8px;
}
.headingBlock {
padding: 0 2px;
.options {
padding: 4px 8px;
}
.title {
@@ -330,18 +368,15 @@
}
.option,
.customTrigger {
.customRow {
align-items: flex-start;
gap: 8px;
padding: 6px;
}
.choiceIcon {
margin-top: 3px;
}
.footer {
align-items: flex-end;
padding: 0 10px;
}
.footerActions {
@@ -350,7 +385,8 @@
}
@media (prefers-reduced-motion: reduce) {
.option {
.option,
.customRow {
transition: none;
}
}

View File

@@ -1,19 +1,27 @@
import { useMemo, useState, type KeyboardEvent } from 'react'
import clsx from 'clsx'
import {
Button, IconCheckOutline16, IconChevronLeftOutline14, IconChevronRightOutline14,
Button, IconCheckOutline14, IconChevronLeftOutline14, IconChevronRightOutline14,
IconCloseOutline16, IconEditOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { LocaleSnapshot, Translate } from '@deepseek-ai/dsh-client-locale/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { PendingQuestion, type QuestionAnswer, type QuestionComposerProps } from './contract/slots.ts'
import css from './QuestionComposer.module.css'
interface DraftAnswer {
selected: string[]
custom: string
customOpen: boolean
skipped: boolean
}
/**
* Displayed feedback: validation feedback is stored as a dictionary key so a
* locale flip re-translates it; carrier failures arrive as raw (untranslated)
* messages and display verbatim.
*/
type Feedback = { key: 'error.incomplete' | 'error.empty' } | { message: string }
/**
* Split the conventional recommendation suffix without changing the answer value.
* @param label - Original option label returned if selected.
@@ -26,17 +34,8 @@ export function parseRecommendedLabel(label: string): { label: string; recommend
: { 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<HTMLTextAreaElement>): boolean {
/** 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.
// eslint-disable-next-line @typescript-eslint/no-deprecated
return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229
@@ -52,17 +51,24 @@ 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])
return <QuestionFlow key={question.key} pending={question} />
return <QuestionFlow key={question.key} pending={question} t={props.t} useLocale={props.useLocale} />
}
function QuestionFlow({ pending }: { pending: PendingQuestion }) {
function QuestionFlow({ pending, t, useLocale }: {
pending: PendingQuestion
t: Translate
useLocale: SnapshotSelectorHook<LocaleSnapshot>
}) {
// Subscription only: t reads the active locale at call time, so the
// revision selector exists to re-render this tree on locale flips.
useLocale(snapshot => snapshot.revision)
const questions = pending.questions
const [index, setIndex] = useState(0)
const [drafts, setDrafts] = useState<DraftAnswer[]>(() => questions.map(question => ({
selected: [], custom: '', customOpen: (question.options?.length ?? 0) === 0, skipped: false,
const [drafts, setDrafts] = useState<DraftAnswer[]>(() => questions.map(() => ({
selected: [], custom: '', skipped: false,
})))
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
const [error, setError] = useState<string | null>(null)
const [error, setError] = useState<Feedback | null>(null)
// index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const question = questions[index]!
@@ -75,7 +81,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
setError(null)
void pending.cancel().catch((cause: unknown) => {
setBusy(null)
setError(cause instanceof Error ? cause.message : String(cause))
setError({ message: cause instanceof Error ? cause.message : String(cause) })
})
}
@@ -91,17 +97,13 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
? current.selected.filter(item => item !== label)
: [...current.selected, label]
: [label]
return { selected, custom: '', customOpen: false, skipped: false }
return { selected, custom: '', 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() !== ''
@@ -111,7 +113,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
const missing = values.findIndex(item => !completed(item))
if (missing >= 0) {
setIndex(missing)
setError('请先完成这道问题。')
setError({ key: 'error.incomplete' })
return
}
const answer: QuestionAnswer = {
@@ -130,13 +132,13 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
setError(null)
void pending.answer(answer).catch((cause: unknown) => {
setBusy(null)
setError(cause instanceof Error ? cause.message : String(cause))
setError({ message: cause instanceof Error ? cause.message : String(cause) })
})
}
const continueFlow = (): void => {
if (!answered(draft)) {
setError('请选择一个选项或填写自定义答案。')
setError({ key: 'error.empty' })
return
}
if (index < questions.length - 1) {
@@ -149,11 +151,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
const skipQuestion = (): void => {
const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index
? {
selected: [], custom: '',
customOpen: (question.options?.length ?? 0) === 0,
skipped: true,
}
? { selected: [], custom: '', skipped: true }
: item)
setDrafts(nextDrafts)
setError(null)
@@ -171,37 +169,17 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
<div className={css.headingBlock}>
{question.header !== undefined && <div className={css.eyebrow}>{question.header}</div>}
<h2 className={css.title} id={`question-${pending.key}-${String(index)}`}>
<span>{question.multiSelect === true
? parseQuestionTitle(question.question)
: question.question}</span>
{question.multiSelect === true && <span className={css.multiSelectHint}></span>}
{question.question}
</h2>
{question.detail !== undefined && <p className={css.detail}>{question.detail}</p>}
</div>
<div className={css.headerActions}>
<span className={css.progress}>{index + 1} / {questions.length}</span>
<button
type="button" className={css.iconButton} aria-label="上一题"
disabled={index === 0 || busy !== null}
onClick={() => { setIndex(index - 1); setError(null) }}
>
<IconChevronLeftOutline14 />
</button>
<button
type="button" className={css.iconButton} aria-label="下一题"
disabled={index === questions.length - 1 || busy !== null}
onClick={() => { setIndex(index + 1); setError(null) }}
>
<IconChevronRightOutline14 />
</button>
<button
type="button" className={css.iconButton} aria-label="放弃整组问题"
title="放弃整组问题"
disabled={busy !== null} onClick={cancelFlow}
>
<IconCloseOutline16 />
</button>
</div>
<button
type="button" className={css.iconButton} aria-label={t('dismiss')}
title={t('dismiss')}
disabled={busy !== null} onClick={cancelFlow}
>
<IconCloseOutline16 />
</button>
</header>
<div className={css.options} role={question.multiSelect === true ? 'group' : 'radiogroup'}>
@@ -211,7 +189,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
return (
<button
type="button" key={`${option.label}-${String(optionIndex)}`}
className={clsx(css.option, selected && css.optionSelected)}
className={clsx(css.option, selected && question.multiSelect !== true && css.optionSelected)}
role={question.multiSelect === true ? 'checkbox' : 'radio'}
aria-checked={selected}
aria-label={display.label}
@@ -223,50 +201,76 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
submitDrafts(drafts)
}}
>
<span className={css.number}>{optionIndex + 1}</span>
{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}></span>}
{display.recommended && <span className={css.badge}>{t('option.recommended')}</span>}
{option.description !== undefined && (
<span className={css.description}>{option.description}</span>
)}
</span>
</span>
<span className={css.choiceIcon}>
{selected ? <IconCheckOutline16 /> : <IconChevronRightOutline14 />}
</span>
</button>
)
})}
<div className={clsx(
css.custom,
draft.customOpen && css.customOpen,
!hasOptions && css.customOptionless,
)}>
{hasOptions && (
<button
type="button" className={css.customTrigger}
disabled={busy !== null} onClick={openCustom}
aria-expanded={draft.customOpen}
>
<span className={css.number}><IconEditOutline16 /></span>
<span></span>
</button>
)}
{draft.customOpen && (
{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={(event) => {
const value = event.target.value
updateDraft(current => ({
...current, selected: [], custom: value, skipped: false,
}))
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !isComposing(event)) {
event.preventDefault()
continueFlow()
}
}}
/>
</div>
)
: (
<textarea
autoFocus
className={css.customInput}
className={css.customTextarea}
value={draft.custom}
disabled={busy !== null}
rows={2}
placeholder="输入你的答案"
placeholder={t('custom.placeholder')}
onChange={(event) => {
const value = event.target.value
updateDraft(current => ({
...current, selected: [], custom: value, customOpen: true, skipped: false,
...current, selected: [], custom: value, skipped: false,
}))
}}
onKeyDown={(event) => {
@@ -277,22 +281,40 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
}}
/>
)}
</div>
</div>
<footer className={css.footer}>
<div className={css.feedback} role="status">{error}</div>
<div className={css.pager}>
<button
type="button" className={css.iconButton} aria-label={t('pager.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('pager.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.message}
</div>
<div className={css.footerActions}>
<Button variant="ghost" size="sm" disabled={busy !== null} onClick={skipQuestion}>
<Button variant="outline" disabled={busy !== null} onClick={skipQuestion}>
{t('action.skip')}
</Button>
<Button
variant="primary" size="sm"
variant="primary"
disabled={busy !== null || !answered(draft)} onClick={continueFlow}
>
{busy === 'answer'
? '正在提交…'
: index === questions.length - 1 ? '提交' : '下一题'}
? t('action.submitting')
: t(index === questions.length - 1 ? 'action.submit' : 'action.next')}
</Button>
</div>
</footer>

View File

@@ -6,12 +6,13 @@
* cancelled error encoding, receipt checks — lives HERE, with the package
* that consumes it.
*/
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { HostObservable, InjectFace, 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-client-connection/client'
import type { LocaleSnapshot, Translate } from '@deepseek-ai/dsh-client-locale/client'
/** The pending question carrier the owner dispatches into the composer slot. */
export type QuestionWait = PendingWait<'question'>
@@ -68,10 +69,25 @@ export class PendingQuestion {
}
/**
* 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. No injected
* share: the carrier plus the domain face above carry the whole behavior
* surface.
* Registrant-injected share: the `question`-namespace translator plus the
* locale snapshot as a hooks-compartment source. `t` reads the active locale
* at call time; the bound `useLocale` subscription is what re-renders the
* composer when the locale flips.
*/
export type QuestionComposerProps = PropsRuntime<'conversation.composer'> & { matched: QuestionWait }
export interface QuestionComposerInjected {
/** Translator bound to the `question` namespace. */
t: Translate
hooks: {
/** Live locale snapshot (bound to the `useLocale` selector hook). */
locale: HostObservable<LocaleSnapshot>
}
}
/**
* Full component props: the framework runtime share (chain currency +
* session/global standard kit), the injected locale share, and the chain
* `matched` share — the entry's selector result, already narrowed to the
* question carrier. Data and verbs ride the carrier plus the domain face.
*/
export type QuestionComposerProps =
PropsRuntime<'conversation.composer'> & InjectFace<QuestionComposerInjected> & { matched: QuestionWait }

View File

@@ -1,18 +1,23 @@
/**
* Web question plugin, browser half: QuestionComposer registered as a
* selector-routed entry of the conversation-declared composer chain. Pure
* consumer — 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); no inject face, no
* service dependency beyond slots. Export discipline: packages/client/AGENTS.md.
* selector-routed entry of the conversation-declared composer chain. The
* selector narrows the owner's currency to the question carrier (matched
* prop); answer/cancel behavior rides the carrier (domain encoding in
* contract/slots.ts PendingQuestion); the inject face carries only the
* locale share (bound translator + snapshot source). Export discipline:
* packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { QuestionWait } from './contract/slots.ts'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { QuestionComposerInjected, QuestionWait } from './contract/slots.ts'
import { en, QUESTION_NS, zh } from './locales.ts'
import { QuestionComposer } from './QuestionComposer.tsx'
export { PendingQuestion } from './contract/slots.ts'
export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './contract/slots.ts'
export type { QuestionAnswer, QuestionComposerInjected, QuestionComposerProps, QuestionWait } from './contract/slots.ts'
export { QUESTION_NS } from './locales.ts'
/**
* Required services (cordis fiber inject). 'conversation' is an ordering
@@ -20,7 +25,7 @@ export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './cont
* declared by ui-conversation's apply, and register() into an undeclared
* slot throws — service waiting orders this apply after the declaring one.
*/
export const inject = ['slots', 'conversation']
export const inject = ['slots', 'conversation', 'locale']
/** Chain routing: claim the composer while a question wait is pending (pure — owner props only). */
function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | null {
@@ -28,14 +33,35 @@ function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | nu
}
/**
* Client plugin body: register the question composer into the composer chain.
* Zero business face — data and verbs both live on the matched carrier.
* Client plugin body: register the composer's bilingual copy and the question
* composer itself into the composer chain. The inject face hands the entry
* its namespace-bound translator plus the locale snapshot source; data and
* verbs live on the matched carrier.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
const slots = ctx.slots
ctx.effect(() => {
const disposers = [
ctx.locale.register(QUESTION_NS, 'zh', zh),
ctx.locale.register(QUESTION_NS, 'en', en),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-question: composer dictionaries')
const injected = (): QuestionComposerInjected => ({
t: ctx.locale.bind(QUESTION_NS),
hooks: {
locale: {
getSnapshot: () => ctx.locale.getLocale(),
subscribe: fn => ctx.on('locale/change', fn),
},
},
})
ctx.effect(
() => slots.register({ name: 'conversation.composer', select: selectQuestion }, QuestionComposer),
() => ctx.slots.register(
{ name: 'conversation.composer', select: selectQuestion, inject: injected },
QuestionComposer,
),
'ui-question: composer chain registration',
)
}

View File

@@ -0,0 +1,39 @@
/**
* Bilingual copy of the question composer, registered under the `question`
* namespace. Question/option text itself arrives from the model verbatim —
* these dictionaries cover only the chrome around it.
*/
import type { LocaleDict } from '@deepseek-ai/dsh-client-locale/client'
/** Namespace owning the question-composer copy. */
export const QUESTION_NS = 'question'
/** Simplified Chinese dictionary (the fallback locale). */
export const zh: LocaleDict = {
'dismiss': '放弃整组问题',
'pager.prev': '上一题',
'pager.next': '下一题',
'option.recommended': '推荐',
'custom.placeholder': '输入你的答案',
'error.incomplete': '请先完成这道问题。',
'error.empty': '请选择一个选项或填写自定义答案。',
'action.skip': '跳过本题',
'action.next': '下一题',
'action.submit': '提交',
'action.submitting': '正在提交…',
}
/** English dictionary. */
export const en: LocaleDict = {
'dismiss': 'Dismiss all questions',
'pager.prev': 'Previous question',
'pager.next': 'Next question',
'option.recommended': 'Recommended',
'custom.placeholder': 'Type your answer',
'error.incomplete': 'Please finish this question first.',
'error.empty': 'Choose an option or type a custom answer.',
'action.skip': 'Skip this question',
'action.next': 'Next',
'action.submit': 'Submit',
'action.submitting': 'Submitting…',
}

View File

@@ -1,16 +1,19 @@
/**
* apply wiring on a real cordis Context + SlotsService: QuestionComposer
* registered as the `question` entry of the conversation-declared composer
* slot with ZERO business face (data and verbs ride the dispatched carrier),
* load-order fail-loud, and fiber-teardown unregistration. Component and
* domain-face behavior is covered props-direct in question-composer.spec.tsx;
* no renderer machinery here.
* apply wiring on a real cordis Context + SlotsService + LocaleService:
* QuestionComposer registered as the `question` entry of the
* conversation-declared composer slot, bilingual dictionaries registered
* under the `question` namespace, the locale share handed through the inject
* face, load-order fail-loud, 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 'cordis'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { QuestionComposerInjected } from '../src/client/contract/slots.ts'
import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
import { apply, inject } from '../src/client/index.ts'
import { apply, inject, QUESTION_NS } from '../src/client/index.ts'
async function bench() {
const ctx = new Context()
@@ -24,12 +27,14 @@ async function bench() {
// 'conversation' inject is an ordering edge (the declaring plugin provides
// it after declaring the chain); the bench declares the chain itself.
ctx.provide('conversation', {})
return { ctx, slots }
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
return { ctx, slots, locale }
}
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['slots', 'conversation'])
expect(inject).toEqual(['slots', 'conversation', 'locale'])
})
it('fails loud when no live entry has declared the composer slot', async () => {
@@ -38,31 +43,46 @@ describe('apply', () => {
// Satisfy the ordering inject without declaring the chain: apply must
// then hit the undeclared-slot throw, not sit waiting on the service.
ctx.provide('conversation', {})
ctx.provide('locale', new LocaleService(ctx))
await expect(ctx.plugin({ inject: [...inject], apply }))
.rejects.toThrow(/slot "conversation.composer" is not declared/)
})
it('registers the question entry: routing selector, no inject face', async () => {
const { ctx, slots } = await bench()
it('registers the question entry: routing selector plus the locale share face', async () => {
const { ctx, slots, locale } = 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.
expect(entry.inject).toBeUndefined()
// 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()
// The inject face carries the namespace-bound translator and the live
// locale snapshot source (subscription rides locale/change).
const face = (entry.inject as unknown as () => QuestionComposerInjected)()
expect(face.t('action.submit')).toBe('提交')
expect(face.hooks.locale.getSnapshot()).toBe(locale.getLocale())
const changed = vi.fn()
const off = face.hooks.locale.subscribe(changed)
locale.setLocale('en')
expect(changed).toHaveBeenCalledTimes(1)
expect(face.t('action.submit')).toBe('Submit')
off()
locale.setLocale('zh')
expect(changed).toHaveBeenCalledTimes(1)
})
it('teardown unregisters the slot entry', async () => {
const { ctx, slots } = await bench()
it('teardown unregisters the slot entry and the dictionaries', async () => {
const { ctx, slots, locale } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(slots.entries('conversation.composer')).toHaveLength(1)
expect(locale.bind(QUESTION_NS)('action.submit')).toBe('提交')
await fiber.dispose()
expect(slots.entries('conversation.composer')).toHaveLength(0)
// Unregistered namespace: the lookup chain bottoms out at the key itself.
expect(locale.bind(QUESTION_NS)('action.submit')).toBe('action.submit')
})
})

View File

@@ -8,20 +8,29 @@ import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { LocaleDict, LocaleSnapshot, Translate } from '@deepseek-ai/dsh-client-locale/client'
import { PendingQuestion } from '../src/client/contract/slots.ts'
import {
QuestionComposer, parseQuestionTitle, parseRecommendedLabel,
} from '../src/client/QuestionComposer.tsx'
import { en, zh } from '../src/client/locales.ts'
import { QuestionComposer, parseRecommendedLabel } from '../src/client/QuestionComposer.tsx'
afterEach(cleanup)
const SID = 's1' as SessionId
/** Framework standard-kit stubs: the composer consumes none of them, the
* composed props type mandates their delivery (framework hooks are plain
* stubs per the client testing discipline). */
/** Dictionary-backed translate stub (the lookup chain is the locale package's contract, not re-tested here). */
const translateOver = (dict: LocaleDict): Translate => key => dict[key] ?? key
/** Locale-share stub: static snapshot, no subscription machinery. */
const useLocale: SnapshotSelectorHook<LocaleSnapshot> = select =>
select({ active: 'zh', locales: [], revision: 0 })
/** Framework standard-kit stubs: the composer consumes only the locale share;
* the composed props type mandates delivery of the rest (framework hooks are
* plain stubs per the client testing discipline). */
const kit = {
sessionId: SID,
t: translateOver(zh),
useLocale,
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>,
@@ -67,6 +76,7 @@ describe('QuestionComposer', () => {
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()
@@ -84,9 +94,8 @@ describe('QuestionComposer', () => {
fireEvent.keyDown(custom, { key: 'Enter' })
expect(screen.getByText('3 / 3')).toBeTruthy()
expect(screen.getByText('选择重要信号')).toBeTruthy()
expect(screen.getByText('可多选')).toBeTruthy()
expect(screen.queryByText('(可多选)')).toBeNull()
// 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: '系统设计' }))
@@ -140,11 +149,10 @@ describe('QuestionComposer', () => {
expect(screen.getByText('3 / 3')).toBeTruthy()
})
it('opens custom input, reports missing skipped answers, and supports header navigation', () => {
it('shows the inline custom input, reports missing answers, and supports pager navigation', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
fireEvent.click(screen.getByRole('button', { name: '其他,请填写自定义答案' }))
expect(screen.getByPlaceholderText('输入你的答案')).toBeTruthy()
fireEvent.click(screen.getByRole('radio', { name: '工程落地型' }))
const emptyCustom = screen.getByPlaceholderText('输入你的答案')
@@ -205,6 +213,16 @@ describe('QuestionComposer', () => {
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={translateOver(en)} />)
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} />)
@@ -260,11 +278,3 @@ describe('parseRecommendedLabel', () => {
expect(parseRecommendedLabel('Plain')).toEqual({ label: 'Plain', recommended: false })
})
})
describe('parseQuestionTitle', () => {
it('removes Chinese and ASCII multi-select suffixes', () => {
expect(parseQuestionTitle('选择信号(可多选)')).toBe('选择信号')
expect(parseQuestionTitle('选择信号 (可多选)')).toBe('选择信号')
expect(parseQuestionTitle('选择信号')).toBe('选择信号')
})
})

View File

@@ -14,6 +14,9 @@
{
"path": "../connection"
},
{
"path": "../locale"
},
{
"path": "../runtime"
},