fix(web): make plan transitions admission-safe
This commit is contained in:
@@ -12,6 +12,8 @@ Per-session UI state (selection, composer draft, active view) lives in the decla
|
||||
|
||||
The default composer's bottom row exposes the session-scoped `'conversation.composer.controls'` list slot to the left of the primary action. Mode and policy features contribute controls through that slot; whole-composer takeovers such as questions remain selector-routed entries of the separate `'conversation.composer'` chain. Pending questions render only through that takeover and are omitted from chat-flow placeholders, while approvals remain visible until their own Web response surface exists.
|
||||
|
||||
Prompt submission has a short local admission phase distinct from model generation. The composer clears the draft, prevents a duplicate send, and waits while the session settles the latest mode selection and the Host accepts the prompt. Admission success releases that lock immediately; the independently streamed running state then keeps Stop available for the model turn. Admission failure restores the submitted draft only when the user has not supplied replacement text and surfaces through the ordinary prompt-error strip.
|
||||
|
||||
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -89,7 +89,7 @@ export function apply(ctx: Context): void {
|
||||
subscribe: fn => slots.subscribe('conversation.view', fn),
|
||||
version: () => slots.getVersion('conversation.view'),
|
||||
},
|
||||
send: (text, mode) => {
|
||||
send: async (text, mode) => {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed === '') return
|
||||
// Optimistic clear with failure restore (choreography lives with the
|
||||
@@ -97,7 +97,12 @@ export function apply(ctx: Context): void {
|
||||
// The store write path stays inside the declared actions set:
|
||||
// restoreDraft itself no-ops once the user typed something new.
|
||||
actions.clearDraft()
|
||||
void scoped.send(trimmed, mode).catch(() => { actions.restoreDraft(trimmed) })
|
||||
try {
|
||||
await scoped.send(trimmed, mode)
|
||||
} catch (error: unknown) {
|
||||
actions.restoreDraft(trimmed)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
stop: () => {
|
||||
scoped.cancel().catch(() => {
|
||||
|
||||
@@ -112,8 +112,8 @@ export interface ConversationInjected {
|
||||
subscribe(fn: () => void): () => void
|
||||
version(): number
|
||||
}
|
||||
/** Send choreography: trims, clears the draft optimistically, restores it on failure. */
|
||||
send(text: string, mode: 'queue' | 'steer'): void
|
||||
/** Send choreography through Host admission: trims, clears the draft optimistically, restores it on failure. */
|
||||
send(text: string, mode: 'queue' | 'steer'): Promise<void>
|
||||
/** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */
|
||||
stop(): void
|
||||
/** Navigate to another session (breadcrumb ancestors). */
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// Breadcrumbs derive from useSessions with a pure parentId walk; the active
|
||||
// view id lives in the chat store's `view` field (per-session by store scope).
|
||||
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -54,11 +54,28 @@ export function ConversationRoot({
|
||||
const promptError = useSession(s => s.promptError)
|
||||
const turns = useSession(s => countTurns(s))
|
||||
const pending = useSession(s => s.pending)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const submittingRef = useRef(false)
|
||||
const aliveRef = useRef(true)
|
||||
|
||||
useEffect(() => () => {
|
||||
aliveRef.current = false
|
||||
}, [])
|
||||
|
||||
const error: InputBarError | null = promptError === null
|
||||
? null
|
||||
: { op: promptError.op, message: `${promptError.error.message}(${promptError.error.code})` }
|
||||
const controls = renderSlot('conversation.composer.controls', {})
|
||||
const submit = (mode: 'queue' | 'steer'): void => {
|
||||
if (submittingRef.current) return
|
||||
submittingRef.current = true
|
||||
setSubmitting(true)
|
||||
const settle = (): void => {
|
||||
submittingRef.current = false
|
||||
if (aliveRef.current) setSubmitting(false)
|
||||
}
|
||||
void send(draft, mode).then(settle, settle)
|
||||
}
|
||||
|
||||
// The default composer doubles as the chain's all-decline fallback: a
|
||||
// pending wait with no registered takeover must still leave the input usable.
|
||||
@@ -66,12 +83,13 @@ export function ConversationRoot({
|
||||
<InputBar
|
||||
draft={draft}
|
||||
running={running}
|
||||
submitting={submitting}
|
||||
disabled={removed}
|
||||
error={error}
|
||||
variant="composer"
|
||||
controls={controls}
|
||||
onDraftChange={actions.setDraft}
|
||||
onSend={(mode) => { send(draft, mode) }}
|
||||
onSend={submit}
|
||||
onStop={stop}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -103,7 +103,8 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
|
||||
<InputBar
|
||||
draft={draft}
|
||||
running={false}
|
||||
disabled={sending}
|
||||
submitting={sending}
|
||||
disabled={false}
|
||||
error={error}
|
||||
variant="hero"
|
||||
placeholder="Message to run task, plan and build"
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// serves the empty state (variant='hero': centered launch card) and the
|
||||
// resident composer (variant='composer') — the empty→content transition is a
|
||||
// position move of this component, never a swap (layout ruling). Running
|
||||
// LOCKS the input: textarea disabled with the draft visible, stop is the only
|
||||
// action; the turn ending re-enables and refocuses.
|
||||
// LOCKS the input while Host admission or generation is active. Admission
|
||||
// settles before model work; running keeps Stop available until the turn ends.
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { KeyboardEvent, MouseEvent, ReactNode } from 'react'
|
||||
@@ -19,6 +19,8 @@ export interface InputBarError {
|
||||
export interface InputBarProps {
|
||||
draft: string
|
||||
running: boolean
|
||||
/** Prompt is waiting for selector settlement or synchronous Host admission. */
|
||||
submitting: boolean
|
||||
disabled: boolean
|
||||
error: InputBarError | null
|
||||
/** Hero = empty-state centered card; composer = resident bottom bar. */
|
||||
@@ -34,7 +36,7 @@ export interface InputBarProps {
|
||||
}
|
||||
|
||||
export function InputBar({
|
||||
draft, running, disabled, error, variant, placeholder, accessory, controls, onDraftChange, onSend, onStop,
|
||||
draft, running, submitting, disabled, error, variant, placeholder, accessory, controls, onDraftChange, onSend, onStop,
|
||||
}: InputBarProps) {
|
||||
const empty = draft.trim() === ''
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
@@ -52,7 +54,7 @@ export function InputBar({
|
||||
|
||||
// Locked while running: the browser drops keystrokes AND focus on a disabled
|
||||
// textarea — no sending mid-turn, stop or wait.
|
||||
const locked = disabled || running
|
||||
const locked = disabled || running || submitting
|
||||
|
||||
// Unlock (mount / session switch / turn end) returns focus to the box.
|
||||
useEffect(() => {
|
||||
@@ -80,14 +82,14 @@ export function InputBar({
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const primaryLabel = running ? '停止' : '发送'
|
||||
const primaryLabel = running ? '停止' : submitting ? '发送中' : '发送'
|
||||
const onPrimary = (): void => {
|
||||
if (running) {
|
||||
onStop()
|
||||
return
|
||||
}
|
||||
/* v8 ignore next -- defensive: the primary button is disabled while empty||disabled, so a click cannot reach the false arm. */
|
||||
if (!empty && !disabled) onSend('queue')
|
||||
if (!empty && !disabled && !submitting) onSend('queue')
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -108,7 +110,13 @@ export function InputBar({
|
||||
className={css.input}
|
||||
value={draft}
|
||||
disabled={locked}
|
||||
placeholder={placeholder ?? (disabled ? '会话不可用' : running ? '回复生成中,可停止后再输入' : '输入消息,Enter 发送,Shift+Enter 换行')}
|
||||
placeholder={placeholder ?? (disabled
|
||||
? '会话不可用'
|
||||
: running
|
||||
? '回复生成中,可停止后再输入'
|
||||
: submitting
|
||||
? '正在发送…'
|
||||
: '输入消息,Enter 发送,Shift+Enter 换行')}
|
||||
rows={2}
|
||||
onChange={(e) => onDraftChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
@@ -123,8 +131,8 @@ export function InputBar({
|
||||
type="button"
|
||||
className={clsx(css.primary, running && css.stopping)}
|
||||
aria-label={primaryLabel}
|
||||
title={running ? '停止本轮' : '发送(Enter)'}
|
||||
disabled={!running && (empty || disabled)}
|
||||
title={running ? '停止本轮' : submitting ? '正在等待发送确认' : '发送(Enter)'}
|
||||
disabled={!running && (empty || disabled || submitting)}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={onPrimary}
|
||||
>
|
||||
|
||||
@@ -147,27 +147,24 @@ describe('conversation slot inject surface', () => {
|
||||
const { instance, injected } = b.conversationSurface(ROOT)
|
||||
// Whitespace-only: no send, and the (whitespace) draft is not cleared.
|
||||
instance.actions.setDraft(' ')
|
||||
injected.send(' ', 'queue')
|
||||
await injected.send(' ', 'queue')
|
||||
expect(b.sessionFake.prompt).not.toHaveBeenCalled()
|
||||
expect(instance.store.getSnapshot().draft).toBe(' ')
|
||||
// Success: cleared and stays cleared.
|
||||
instance.actions.setDraft('hello')
|
||||
injected.send('hello', 'queue')
|
||||
await injected.send('hello', 'queue')
|
||||
expect(instance.store.getSnapshot().draft).toBe('')
|
||||
await Promise.resolve()
|
||||
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue')
|
||||
// Failure: restored (draft still empty when the rejection lands).
|
||||
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
|
||||
instance.actions.setDraft('retry me')
|
||||
injected.send('retry me', 'queue')
|
||||
await vi.waitFor(() => {
|
||||
expect(instance.store.getSnapshot().draft).toBe('retry me')
|
||||
})
|
||||
await expect(injected.send('retry me', 'queue')).rejects.toThrow(/agent-busy: b/)
|
||||
expect(instance.store.getSnapshot().draft).toBe('retry me')
|
||||
// Failure landing after new typing: no clobber (restoreDraft fills empty only).
|
||||
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
|
||||
injected.send('retry me', 'queue')
|
||||
const failed = injected.send('retry me', 'queue').catch(() => {})
|
||||
instance.actions.setDraft('typed during flight')
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
await failed
|
||||
expect(instance.store.getSnapshot().draft).toBe('typed during flight')
|
||||
// Stop failure is swallowed (promptError owns the surface).
|
||||
b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x' } })
|
||||
|
||||
@@ -12,7 +12,7 @@ afterEach(cleanup)
|
||||
|
||||
function setup(over?: Partial<InputBarProps>) {
|
||||
const props: InputBarProps = {
|
||||
draft: 'hello', running: false, disabled: false, error: null,
|
||||
draft: 'hello', running: false, submitting: false, disabled: false, error: null,
|
||||
variant: 'composer',
|
||||
onDraftChange: vi.fn(), onSend: vi.fn(), onStop: vi.fn(),
|
||||
...over,
|
||||
@@ -83,6 +83,15 @@ describe('running lock and primary button', () => {
|
||||
expect(props.onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('submission locks duplicate sends only until Host admission settles', () => {
|
||||
const { textarea, button, props } = setup({ submitting: true })
|
||||
expect(textarea.disabled).toBe(true)
|
||||
expect(textarea.placeholder).toBe('正在发送…')
|
||||
expect(button.disabled).toBe(true)
|
||||
fireEvent.click(button)
|
||||
expect(props.onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('idle primary sends and disables on empty draft', () => {
|
||||
const { button, props } = setup()
|
||||
fireEvent.click(button)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* share is a REAL createChatStore().create() instance (same construction path
|
||||
* as production), injected callbacks are spies.
|
||||
*/
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -106,14 +106,14 @@ describe('ConversationRoot', () => {
|
||||
tabs: ViewTab[], activeView?: string, init: Partial<FakeSnapshot> = {},
|
||||
renderSlotChain?: ConversationRootProps['renderSlotChain'],
|
||||
) {
|
||||
const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }], ...init })
|
||||
const { useSession, store: session } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }], ...init })
|
||||
const { useSessions } = fakeSessions([
|
||||
{ id: 'root', title: 'proj' },
|
||||
{ id: 's1', title: 'child', parentId: 'root' },
|
||||
])
|
||||
const chat = createChatStore().create()
|
||||
if (activeView !== undefined) chat.actions.setView(activeView)
|
||||
const send = vi.fn()
|
||||
const send = vi.fn(() => Promise.resolve())
|
||||
const stop = vi.fn()
|
||||
const open = vi.fn()
|
||||
// The renderSlot share as the outlet would bake it: renders a marker for
|
||||
@@ -141,7 +141,7 @@ describe('ConversationRoot', () => {
|
||||
stop={stop}
|
||||
open={open}
|
||||
/>)
|
||||
return { ui, chat, send, stop, open, renderSlot }
|
||||
return { ui, chat, session, send, stop, open, renderSlot }
|
||||
}
|
||||
|
||||
const tab = (id: string, label: string): ViewTab => ({ id, label })
|
||||
@@ -189,6 +189,37 @@ describe('ConversationRoot', () => {
|
||||
expect(send).toHaveBeenCalledWith('hi', 'queue')
|
||||
})
|
||||
|
||||
it('locks duplicate sends only while prompt admission is unresolved', async () => {
|
||||
const { session, send, stop } = bench([tab('chat', 'Chat')])
|
||||
let resolve!: () => void
|
||||
send.mockImplementationOnce(() => new Promise<void>((done) => { resolve = done }))
|
||||
const box = screen.getByPlaceholderText(/输入消息/)
|
||||
fireEvent.change(box, { target: { value: 'wait for mode' } })
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
|
||||
expect((screen.getByRole('button', { name: '发送中' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect((box as HTMLTextAreaElement).disabled).toBe(true)
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
|
||||
session.set({ ...session.getSnapshot(), running: true })
|
||||
const stopButton = await screen.findByRole('button', { name: '停止' }) as HTMLButtonElement
|
||||
expect(stopButton.disabled).toBe(false)
|
||||
fireEvent.click(stopButton)
|
||||
expect(stop).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolve()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: '停止' })).toBeTruthy()
|
||||
})
|
||||
expect((box as HTMLTextAreaElement).disabled).toBe(true)
|
||||
session.set({ ...session.getSnapshot(), running: false })
|
||||
await waitFor(() => {
|
||||
expect((screen.getByRole('button', { name: '发送' }) as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
expect((box as HTMLTextAreaElement).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('dispatches the pending list to the composer chain; all-decline falls back to InputBar', () => {
|
||||
const wait = new PendingWait('question', RpcId('rq'), sid('s1'),
|
||||
{ questions: [{ id: 'mode', question: 'Choose?', options: [{ label: 'Fast' }] }] } as PendingWait<'question'>['payload'], vi.fn())
|
||||
|
||||
Reference in New Issue
Block a user