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

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