fix(web): make plan transitions admission-safe

This commit is contained in:
fz
2026-07-24 16:55:04 +08:00
parent aed744dbbe
commit e90b3bc5a2
18 changed files with 214 additions and 61 deletions

View File

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

View File

@@ -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(() => {

View File

@@ -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). */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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())

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-client-ui-plan
Web plan-mode feature with two lifecycle-coupled halves. The node entry mounts `@deepseek-ai/dsh-plan-mode` with the Web product policy; the browser entry contributes a session-scoped selector to `conversation.composer.controls`.
Web plan-mode feature with two lifecycle-coupled halves. The node entry mounts `@deepseek-ai/dsh-plan-mode` with the Web product's active and default mode policies; the browser entry contributes a session-scoped selector to `conversation.composer.controls`.
The selector distinguishes unavailable capability (`planMode === null`), committed mode (`active`), and the target queued for the next model-request boundary (`pending`, including `pending: false`). Selecting a mode never cancels a running turn. It remains available while generation is running, disables only during its own RPC, and displays the host-confirmed pending target until a logged `plan/mode` event commits it. The transparent native select mirrors keyboard focus onto the visible chip and carries a dynamic accessible description of the committed and pending modes.
@@ -8,11 +8,11 @@ The model exits plan mode through the stable `exit_plan_mode` tool. Its plan rev
## Model Experience
Indirectly, through `@deepseek-ai/dsh-plan-mode`; that package owns policy activation, the exit-tool schema and rendering, logged state, and request-boundary transitions, while this package supplies the Web composition's section text.
Indirectly, through complementary Web system-prompt sections: active mode supplies the planning policy, while default mode explicitly states that the session is not planning, permits normal implementation work, and tells the model not to call the still-registered `exit_plan_mode` tool; `@deepseek-ai/dsh-plan-mode` continues to own the exit-tool schema and rendering, logged state, and request-boundary transitions.
#### KV Cache effect
Entering or leaving plan mode changes the active system-prompt section and therefore the request prefix. The stable exit-tool registration avoids an additional tool-catalog shape change across the same transition.
Entering or leaving plan mode swaps the active Web mode section and therefore changes the request prefix. The stable exit-tool registration avoids an additional tool-catalog shape change across the same transition.
## Known Limitations and Deferred Work

View File

@@ -45,9 +45,11 @@
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",

View File

@@ -3,7 +3,7 @@
* logged plan-mode service with the Web product's planning policy.
*/
import type { Context } from 'cordis'
import PlanModeService from '@deepseek-ai/dsh-plan-mode'
import PlanModeService, { foldPlanMode } from '@deepseek-ai/dsh-plan-mode'
/** Host services required by plan mode. */
export const inject = ['tools', 'systemPrompt']
@@ -21,10 +21,20 @@ Make the plan decision-complete: state the goal and success criteria; group impl
When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation.`
/** Web product-owned guidance rendered while plan mode is inactive. */
export const WEB_DEFAULT_SECTION = 'You are in default mode, not plan mode. Follow the user\'s request normally, including implementing changes when requested. Do not call exit_plan_mode in default mode. It remains in the tool catalog only for request-cache stability and becomes valid only after the user switches this session to plan mode. This current mode statement overrides earlier conversational text that described the session as being in plan mode.'
/**
* Mount plan mode for hosts that selected the Web plan plugin.
* @param ctx - Host context carrying tools and systemPrompt.
*/
export function apply(ctx: Context): void {
ctx.systemPrompt.section({
name: 'plan:default-policy',
order: 50,
text: context => context.agent !== undefined && !foldPlanMode(context.agent.session.events)
? WEB_DEFAULT_SECTION
: '',
})
ctx.plugin(PlanModeService, { section: WEB_PLAN_SECTION })
}

View File

@@ -4,7 +4,9 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { EXIT_PLAN_MODE } from '@deepseek-ai/dsh-plan-mode'
import { WEB_PLAN_SECTION, apply, inject } from '../src/index.ts'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { WEB_DEFAULT_SECTION, WEB_PLAN_SECTION, apply, inject } from '../src/index.ts'
let ctx: Context | undefined
@@ -27,13 +29,45 @@ describe('ui-plan node plugin', () => {
expect(WEB_PLAN_SECTION).toContain('Stay in plan mode until exit_plan_mode succeeds')
expect(WEB_PLAN_SECTION).toContain('Do not edit or write files')
expect(WEB_PLAN_SECTION).toContain('Make exit_plan_mode the only and final tool call')
expect(WEB_DEFAULT_SECTION).toContain('default mode, not plan mode')
expect(WEB_DEFAULT_SECTION).toContain('Do not call exit_plan_mode in default mode')
expect((await ctx.systemPrompt.assemble()).sections)
.toEqual(expect.arrayContaining([expect.objectContaining({ name: 'plan:policy', text: '' })]))
.toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'plan:default-policy', text: '' }),
expect.objectContaining({ name: 'plan:policy', text: '' }),
]))
await feature.dispose()
expect(ctx.get('planMode')).toBeUndefined()
expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeUndefined()
expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name))
.not.toContain('plan:policy')
.toEqual(expect.not.arrayContaining(['plan:default-policy', 'plan:policy']))
})
it('states the exact Web collaboration mode at every agent assembly', async () => {
ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(UserInteractionService)
const feature = ctx.plugin({ inject: [...inject], apply })
await feature.await()
const events: SessionEvent[] = []
const agent = { session: { events } } as unknown as Agent
let sections = (await ctx.systemPrompt.assemble({ agent })).sections
expect(sections.find(section => section.name === 'plan:default-policy')?.text)
.toBe(WEB_DEFAULT_SECTION)
expect(sections.find(section => section.name === 'plan:policy')?.text).toBe('')
events.push({
type: 'plan/mode',
seq: 0,
time: 1,
data: { active: true },
})
sections = (await ctx.systemPrompt.assemble({ agent })).sections
expect(sections.find(section => section.name === 'plan:default-policy')?.text).toBe('')
expect(sections.find(section => section.name === 'plan:policy')?.text)
.toBe(WEB_PLAN_SECTION)
})
})