fix(web): address steering review feedback

This commit is contained in:
imccyu
2026-08-02 17:50:00 +08:00
parent cf918fa05d
commit 7d08e43720
27 changed files with 248 additions and 78 deletions

View File

@@ -222,7 +222,8 @@ export function apply(ctx: Context): void {
if (sessionId === undefined) {
return {
keyboard: undefined,
resolveSubmitMode: (running, gesture) => submissionPolicy.resolve(running, gesture),
resolveSubmitMode: (running, gesture, steeringAvailable) =>
submissionPolicy.resolve(running, gesture, steeringAvailable),
toggleCommandMenu: undefined,
stop: undefined,
command: undefined,
@@ -233,7 +234,8 @@ export function apply(ctx: Context): void {
const slash = inputHub.slash(sessionId)
return {
keyboard: shell,
resolveSubmitMode: (running, gesture) => submissionPolicy.resolve(running, gesture),
resolveSubmitMode: (running, gesture, steeringAvailable) =>
submissionPolicy.resolve(running, gesture, steeringAvailable),
toggleCommandMenu: slash === undefined
? undefined
: (selection) => {

View File

@@ -249,10 +249,10 @@ export function ChatView({
const selectedCallId = useStore(s => s.selection?.callId)
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
const pendingSteering = useMemo(() => {
const durable = new Set(nodes.flatMap(node => node.kind === 'steering' ? [node.messageId] : []))
return inbox.filter(item => item.placement === 'steering' && !durable.has(item.messageId))
}, [inbox, nodes])
const pendingSteering = useMemo(
() => inbox.filter(item => item.placement === 'steering'),
[inbox],
)
const activeRetry = useMemo(() => activeRetrySeq(nodes, running), [nodes, running])
// Only the last content assistant of each turn owns IconActions; mid-turn
// text (before tools) omits `time` so AssistantMarkdown stays chrome-free.

View File

@@ -287,7 +287,11 @@ export interface ComposerBarInjected {
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane); absent with the session. */
keyboard: ComposerKeyboard | undefined
/** Resolve one keyboard submission gesture against the current running state and persisted preference. */
resolveSubmitMode: (running: boolean, gesture: ComposerSubmitGesture) => InputSubmitMode
resolveSubmitMode: (
running: boolean,
gesture: ComposerSubmitGesture,
steeringAvailable: boolean,
) => InputSubmitMode
/** Toggle the shared slash menu with only its command source; absent without ui-slash or a session. */
toggleCommandMenu: ((selection: EditSelection) => void) | undefined
/** Cancel the in-flight turn; absent with the session. */

View File

@@ -27,10 +27,15 @@ export class ComposerSubmissionPolicy {
* Resolve one keyboard gesture without changing state.
* @param running - whether the addressed agent currently reports busy.
* @param gesture - plain Enter or the Cmd/Ctrl-accelerated chord.
* @returns Queue outside busy state; otherwise the preferred mode or its opposite.
* @param steeringAvailable - whether this session transport supports steering.
* @returns Queue outside steer-capable busy state; otherwise the preferred mode or its opposite.
*/
resolve(running: boolean, gesture: ComposerSubmitGesture): InputSubmitMode {
if (!running) return 'queue'
resolve(
running: boolean,
gesture: ComposerSubmitGesture,
steeringAvailable: boolean,
): InputSubmitMode {
if (!running || !steeringAvailable) return 'queue'
const preferred = this.busyEnter.getSnapshot()
if (gesture === 'enter') return preferred
return preferred === 'queue' ? 'steer' : 'queue'

View File

@@ -97,7 +97,7 @@ export const zh = {
'queue.steer.unavailable': '仅运行中可插话发送',
'queue.editFailed': '编辑失败:这条消息可能已经开始发送。',
'queue.removeFailed': '删除失败:这条消息可能已经开始发送。',
'queue.steerFailed': '插话失败:当前回复已结束,或这条消息已经开始发送。',
'queue.steerFailed': '插话发送失败,请重试。',
'terminal.signal': '信号 {signal}',
'terminal.exitCode': '退出码 {code}',
'terminal.running': '运行中',
@@ -204,7 +204,7 @@ export const en = {
'queue.steer.unavailable': 'Steering is available only while the agent is running',
'queue.editFailed': 'Edit failed: this message may have already started sending.',
'queue.removeFailed': 'Removal failed: this message may have already started sending.',
'queue.steerFailed': 'Steering failed: the current response ended or this message already started sending.',
'queue.steerFailed': 'Steering failed. Try again.',
'terminal.signal': 'signal {signal}',
'terminal.exitCode': 'exit code {code}',
'terminal.running': 'Running',

View File

@@ -32,6 +32,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
const inbox = useSession(s => s.queue)
const queue = useMemo(() => inbox.filter(row => row.placement === 'queued'), [inbox])
const running = useSession(s => s.running)
const queueMutable = useSession(s => s.subagent === null)
const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null)
const [busy, setBusy] = useState<QueueItemId | null>(null)
const [collapsed, setCollapsed] = useState(true)
@@ -39,12 +40,12 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
useEffect(() => {
if (queue.length === 0 && !collapsed) setCollapsed(true)
if (editing !== null && !queue.some(row => row.id === editing.id)) setEditing(null)
}, [collapsed, editing, queue])
if (editing !== null && (!queueMutable || !queue.some(row => row.id === editing.id))) setEditing(null)
}, [collapsed, editing, queue, queueMutable])
if (queue.length === 0) return null
const interactionActive = editing !== null || busy !== null
const interactionActive = queueMutable && (editing !== null || busy !== null)
const expanded = !collapsed || interactionActive
const listVisible = queue.length === 1 || expanded
@@ -116,7 +117,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
/>
)
: <span className={css.preview}>{row.preview}</span>}
<div className={css.actions}>
{queueMutable && <div className={css.actions}>
{editing?.id === row.id
? (
<>
@@ -190,7 +191,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
</button>
</>
)}
</div>
</div>}
</li>
))}
</ul>

View File

@@ -34,7 +34,7 @@ export interface IConversation {
* Apply one edit, remove, or strict steer operation to a pending queue occurrence.
* @param itemId - agent-owned inbox occurrence identity.
* @param action - requested queue operation.
* @returns completion; business failures reject.
* @returns completion; converged strict-steer races resolve, while other failures reject.
*/
updateQueue(itemId: QueueItemId, action: QueueAction): Promise<void>
/**
@@ -82,6 +82,10 @@ export class ConversationService extends Service implements IConversation {
const session = this.scopedSession('updateQueue')
const result = await session.updateQueue(itemId, action)
if (!result.ok) {
if (
action.kind === 'steer'
&& (result.error.code === 'steer-unavailable' || result.error.code === 'queue-item-not-found')
) return
throw new Error(`conversation.updateQueue failed: ${result.error.code}: ${result.error.message}`)
}
}

View File

@@ -181,7 +181,11 @@ export function InputBar({
e.preventDefault()
if (e.repeat) return // held-down Enter must not machine-gun sends
if (locked || machineBusy) return
keyboard.submit(resolveSubmitMode(running, e.ctrlKey || e.metaKey ? 'accelerated' : 'enter'))
keyboard.submit(resolveSubmitMode(
running,
e.ctrlKey || e.metaKey ? 'accelerated' : 'enter',
subagent === null,
))
}
const onChange = (e: ChangeEvent<HTMLTextAreaElement>): void => {