fix(web): allow follow-ups to running subagents

This commit is contained in:
imccyu
2026-08-02 12:51:11 +08:00
committed by Tianyi Cui
parent 130410bb98
commit 42d34473af
2 changed files with 43 additions and 7 deletions
@@ -285,13 +285,14 @@ export function InputBar({
} }
const ordinary = subagent === null const ordinary = subagent === null
const primaryLabel = running && ordinary ? t('input.stop') : t('input.send') const stopping = running && ordinary
const primaryLabel = stopping ? t('input.stop') : t('input.send')
const onPrimary = (): void => { const onPrimary = (): void => {
if (inputActions === undefined || stop === undefined) return // absent machine: the button is disabled if (stopping) {
if (running && ordinary) { stop?.()
stop()
return return
} }
if (inputActions === undefined) return // absent machine: the button is disabled
/* v8 ignore next -- defensive: the primary button is disabled while empty||disabled, so a click cannot reach the false arm. */ /* v8 ignore next -- defensive: the primary button is disabled while empty||disabled, so a click cannot reach the false arm. */
if (!empty && !disabled && !machineBusy) inputActions.submit() if (!empty && !disabled && !machineBusy) inputActions.submit()
} }
@@ -467,11 +468,11 @@ export function InputBar({
className={css.primary} className={css.primary}
aria-label={primaryLabel} aria-label={primaryLabel}
title={primaryLabel} title={primaryLabel}
disabled={!running && (empty || disabled || machineBusy)} disabled={stopping ? stop === undefined : empty || disabled || machineBusy}
onMouseDown={keepFocus} onMouseDown={keepFocus}
onClick={onPrimary} onClick={onPrimary}
> >
{running ? ( {stopping ? (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden> <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" /> <rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" />
</svg> </svg>
@@ -41,6 +41,7 @@ interface BenchOptions {
permissions?: { options: { value: string; name: string; description?: string }[]; currentValue: string } permissions?: { options: { value: string; name: string; description?: string }[]; currentValue: string }
draft?: string draft?: string
running?: boolean running?: boolean
subagent?: Exclude<ConversationSnapshot['subagent'], null>
disabled?: boolean disabled?: boolean
promptError?: ConversationSnapshot['promptError'] promptError?: ConversationSnapshot['promptError']
variant?: 'hero' | 'composer' variant?: 'hero' | 'composer'
@@ -76,6 +77,7 @@ function bench(over?: BenchOptions) {
if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft) if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft)
const session = createSnapshotStore<ConversationSnapshot>(snapshotOf({ const session = createSnapshotStore<ConversationSnapshot>(snapshotOf({
running: over?.running ?? false, running: over?.running ?? false,
subagent: over?.subagent ?? null,
removed: over?.disabled ?? false, removed: over?.disabled ?? false,
promptError: over?.promptError ?? null, promptError: over?.promptError ?? null,
})) }))
@@ -124,8 +126,9 @@ function bench(over?: BenchOptions) {
const view = render(<InputBar {...props} />) const view = render(<InputBar {...props} />)
const textarea = view.container.querySelector('textarea')! const textarea = view.container.querySelector('textarea')!
// aria-label (not role name): title carries the same label and would double-match. // aria-label (not role name): title carries the same label and would double-match.
const stopping = over?.running === true && over.subagent === undefined
const button = view.container.querySelector<HTMLButtonElement>( const button = view.container.querySelector<HTMLButtonElement>(
`button[aria-label="${over?.running === true ? '停止生成' : '发送消息'}"]`, `button[aria-label="${stopping ? '停止生成' : '发送消息'}"]`,
)! )!
return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher } return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher }
} }
@@ -208,6 +211,38 @@ describe('running and lock semantics (queue cut 1)', () => {
expect(stop).toHaveBeenCalledTimes(1) expect(stop).toHaveBeenCalledTimes(1)
}) })
it('running subagent primary admits a follow-up instead of exposing Stop', () => {
const { button, sink, stop } = bench({
running: true,
draft: '后续消息',
subagent: {
address: {
parentSessionId: 'parent' as SessionId,
childSessionId: SID,
mode: 'continuable',
},
parentAvailable: true,
},
})
expect(button.getAttribute('aria-label')).toBe('发送消息')
fireEvent.click(button)
expect(sink).toHaveBeenCalledWith('后续消息')
expect(stop).not.toHaveBeenCalled()
const empty = bench({
running: true,
subagent: {
address: {
parentSessionId: 'parent' as SessionId,
childSessionId: SID,
mode: 'continuable',
},
parentAvailable: true,
},
})
expect(empty.button.disabled).toBe(true)
})
it('disabled (session removed) locks the textarea and chrome', () => { it('disabled (session removed) locks the textarea and chrome', () => {
const { textarea, view } = bench({ disabled: true }) const { textarea, view } = bench({ disabled: true })
expect(textarea.disabled).toBe(true) expect(textarea.disabled).toBe(true)