feat(gui): add ask-user question composer

This commit is contained in:
Yichen Jiang
2026-07-22 23:39:50 +08:00
parent b51d2b3d67
commit 03889cee1a
58 changed files with 1905 additions and 115 deletions

View File

@@ -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.

View File

@@ -62,6 +62,8 @@ export function apply(ctx: Context): void {
const layout = need<LayoutService>(ctx, 'layout')
const i18n = need<I18nService>(ctx, 'i18n')
const slots = need<SlotsService>(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
}

View File

@@ -270,7 +270,9 @@ export function createChatView(deps: ChatViewDeps): FC<ConvViewProps> {
))}
</div>
)}
{pending.map((item) => <PendingCard key={item.rpcId} item={item} />)}
{pending.map((item) => item.kind === 'approval'
? <PendingCard key={item.rpcId} item={item} />
: null)}
</div>
</div>
{!atBottom && (

View File

@@ -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<PendingInteraction, { kind: '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.toolName}</span></div>
{item.reason !== undefined && <div className={css.reason}>{item.reason}</div>}
</>
) : (
<>
<div className={css.title}>等待回答({item.questions.length} 题)</div>
<JsonBlock label="问题内容" payload={item.questions} />
</>
)}
<div className={css.title}>等待审批:<span className={css.mono}>{item.toolName}</span></div>
{item.reason !== undefined && <div className={css.reason}>{item.reason}</div>}
<div className={css.hint}>请在原客户端处理(web 端作答后续里程碑提供)</div>
</div>
)

View File

@@ -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<PendingInteraction, { kind: 'question' }>
}
/** Full conversation-slot component props: owner share & standard share & injected share. */

View File

@@ -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
}
}
}

View File

@@ -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)}
</div>
<InputBar
draft={draft}
running={running}
disabled={removed}
error={error}
variant="composer"
onDraftChange={composer.setDraft}
onSend={composer.send}
onStop={composer.stop}
/>
{question?.kind === 'question'
? slots.renderSlot('conversation.composer', { interaction: question }, {
entryKey: 'question',
fallback: <ComposerInput {...{ draft, running, removed, error, composer }} />,
})
: <ComposerInput {...{ draft, running, removed, error, composer }} />}
</div>
)
}
function ComposerInput({ draft, running, removed, error, composer }: {
draft: string
running: boolean
removed: boolean
error: InputBarError | null
composer: ConversationSlotProps['composer']
}) {
return (
<InputBar
draft={draft}
running={running}
disabled={removed}
error={error}
variant="composer"
onDraftChange={composer.setDraft}
onSend={composer.send}
onStop={composer.stop}
/>
)
}
/** 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

View File

@@ -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(<h.ChatView {...h.props} />)
expect(view.getByText(/等待审批/)).toBeTruthy()
expect(view.queryByText('Composer only?')).toBeNull()
})
})

View File

@@ -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(
<PendingCard item={{ kind: 'question', rpcId: 'r1' as RpcId, questions: [{}, {}] }} />,
)
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

@@ -10,11 +10,14 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConversationSnapshot, 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 } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConversationInjected, SelectionTarget, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
afterEach(cleanup)
const SID = 's1' as SessionId
const fallbackSlots: ConversationInjected['slots'] = {
renderSlot: (_key, _props, opts) => 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={() => <div data-testid="view-body" />}
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) => <div data-testid={`body-${entry.id}`} />}
slots={fallbackSlots}
/>,
)
expect(view.getByTestId('body-chat')).toBeTruthy()

View File

@@ -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<FakeSnapshot> = {}) {
const store = createSnapshotStore<FakeSnapshot>({
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<FakeSnapshot> = {},
renderSlot?: ConversationInjected['slots']['renderSlot'],
) {
const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }], ...init })
const activeStore = createSnapshotStore<string | undefined>(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 <div data-testid={`view-${entry.id}`} /> }}
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(() => <div>question takeover</div>) 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', () => {