fix(web): make plan transitions admission-safe
This commit is contained in:
@@ -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}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user