Merge origin/master: session projection base, command channel, composer seats
Resolution follows the reattachment plan: the #587 wire layer (planMode/ setPlanMode RPC, prompt.planMode, client Session projection fences) is dropped in favor of the session-projection base now on master; master structure wins in all shared files. Kept from the PR side: the approval-only pending filter in ChatView (questions render solely through the composer takeover) and the auto-merged QuestionComposer improvements. The deleted host/runtime package and retired test carriers are removed with master.
This commit is contained in:
6
packages/client/ui-question/README.i18n.yaml
Normal file
6
packages/client/ui-question/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 28132d1d0643f5e8f658ab77de9017467da2d172
|
||||
README.zh.md: c70e77fc90eb5b226ceabd8a1e7cc7ca6c011c40
|
||||
@@ -1,5 +1,7 @@
|
||||
# @deepseek-ai/dsh-client-ui-question
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Web `ask_user_question` feature plugin. Its host half mounts `dsh-tool-ask-user` only when the Web feature is selected; its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot.
|
||||
|
||||
The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
|
||||
|
||||
22
packages/client/ui-question/README.zh.md
Normal file
22
packages/client/ui-question/README.zh.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# @deepseek-ai/dsh-client-ui-question
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Web `ask_user_question` 功能插件。只有选择 Web 功能时,其主机侧才会挂载 `dsh-tool-ask-user`;浏览器侧会把 `question` 配置项注册到会话拥有的 `conversation.composer` 键控 slot 中。
|
||||
|
||||
组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。单选选项会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
|
||||
|
||||
选择状态只存在于以请求 rpcId 为 key 的组件本地。使用相同 id 回放时,只要组件仍挂载,就会保留草稿;主机发出的 `question/resolved` 则会移除编辑器。主机仍具有最终决定权:HTTP 交付成功不会在本地移除待处理状态。
|
||||
|
||||
## 模型体验
|
||||
|
||||
通过 `dsh-tool-ask-user` 间接影响;该包拥有模型可见的工具 schema 和结构化结果。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
不会直接失效;模型可见的工具调用与结果由 `dsh-tool-ask-user` 拥有。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **未提交的草稿不持久**:重新连接再同步或完整刷新页面时,会恢复主机拥有且 rpcId 相同的待处理请求,但编辑器卸载会重置本地选项和自定义文本草稿。
|
||||
- **每次只有一个请求拥有编辑器**:后续待处理请求仍留在会话快照中,并在较早请求解决后显示。
|
||||
@@ -19,6 +19,13 @@
|
||||
background: var(--dsw-specific-input-major);
|
||||
box-shadow: var(--dsw-shadow-lv1-blur);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
/* Elevated surface in dark, same as the menus: the option list inside scrolls
|
||||
once the card hits the cap above, so the thumb takes the l2 pair. Declared
|
||||
on the card because the elevation belongs to the surface, and the custom
|
||||
properties inherit down to `.options` (see ui-theme styles/scrollbar.css
|
||||
for the rebinding contract). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
.card,
|
||||
@@ -130,6 +137,11 @@
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
/* 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;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
@@ -210,6 +222,9 @@
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ export function parseQuestionTitle(title: string): string {
|
||||
|
||||
/** Return whether a textarea key event belongs to an active IME composition. */
|
||||
function isComposing(event: KeyboardEvent<HTMLTextAreaElement>): 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
|
||||
}
|
||||
|
||||
@@ -61,7 +63,10 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
||||
})))
|
||||
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
|
||||
const [error, setError] = useState<string | 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]!
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const draft = drafts[index]!
|
||||
const hasOptions = (question.options?.length ?? 0) > 0
|
||||
|
||||
@@ -145,10 +150,10 @@ 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: '',
|
||||
customOpen: (question.options?.length ?? 0) === 0,
|
||||
skipped: true,
|
||||
}
|
||||
: item)
|
||||
setDrafts(nextDrafts)
|
||||
setError(null)
|
||||
|
||||
@@ -16,9 +16,7 @@ async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
// Stand-in for ui-conversation's conversation entry: the composer slot only
|
||||
// exists while a live entry declares it in children (declaration account:
|
||||
// design §2.2).
|
||||
// The composer slot exists only while its declaring entry is live.
|
||||
slots.register(
|
||||
{ name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never,
|
||||
() => null,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, SessionId, SessionListState, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -22,6 +24,10 @@ const kit = {
|
||||
sessionId: SID,
|
||||
useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
|
||||
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
|
||||
useWorkspaces: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<WorkspaceListState>,
|
||||
useProjection: (() => undefined) as never,
|
||||
useInput: (() => { throw new Error('unused') }) as never,
|
||||
inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never,
|
||||
}
|
||||
|
||||
const QUESTIONS = [
|
||||
@@ -45,7 +51,7 @@ const QUESTIONS = [
|
||||
/** Carrier fixture: a real PendingWait over a scripted respond carrier. */
|
||||
function wait(rpcId = 'question-1', respond = vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: true }))) {
|
||||
const carrier = new PendingWait(
|
||||
'question', RpcId(rpcId), SID, { questions: QUESTIONS } as PendingWait<'question'>['payload'], respond)
|
||||
'question', RpcId(rpcId), SID, { questions: QUESTIONS }, respond)
|
||||
return { carrier, respond }
|
||||
}
|
||||
|
||||
@@ -98,7 +104,7 @@ describe('QuestionComposer', () => {
|
||||
{ id: 'detail', selected: [], custom: '要能独立排查线上问题' },
|
||||
{ id: 'signals', selected: ['系统设计', '代码质量'] },
|
||||
]))
|
||||
expect((screen.getByRole('button', { name: '正在提交…' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: '正在提交…' }).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('renders plan detail through the shared assistant Markdown primitive', () => {
|
||||
@@ -195,7 +201,7 @@ describe('QuestionComposer', () => {
|
||||
// Receipt rejection surfaces through the domain face's thrown message.
|
||||
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
|
||||
expect(await screen.findByText('question cancellation rejected: bad-response')).toBeTruthy()
|
||||
expect((screen.getByRole('button', { name: '跳过本题' }) as HTMLButtonElement).disabled).toBe(false)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: '跳过本题' }).disabled).toBe(false)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
|
||||
expect(await screen.findByText('第二次取消失败')).toBeTruthy()
|
||||
@@ -221,7 +227,7 @@ describe('QuestionComposer', () => {
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交' }))
|
||||
expect(await screen.findByText('网络中断')).toBeTruthy()
|
||||
expect((screen.getByRole('button', { name: '提交' }) as HTMLButtonElement).disabled).toBe(false)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: '提交' }).disabled).toBe(false)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交' }))
|
||||
expect(await screen.findByText('字符串错误')).toBeTruthy()
|
||||
|
||||
Reference in New Issue
Block a user