diff --git a/docs/design/web-shell/webshell-composer-placeholders.md b/docs/design/web-shell/webshell-composer-placeholders.md index fd9ca1fe412..5a48ac97469 100644 --- a/docs/design/web-shell/webshell-composer-placeholders.md +++ b/docs/design/web-shell/webshell-composer-placeholders.md @@ -19,7 +19,7 @@ translations. `WebShellProps` accepts an optional `composerPlaceholders` map: ```ts -type WebShellComposerPlaceholderState = 'idle' | 'loading' | 'processing'; +type WebShellComposerPlaceholderState = 'idle' | 'processing'; type WebShellComposerPlaceholders = Partial< Record @@ -36,12 +36,10 @@ The composer resolves one semantic state before resolving copy: | State | Condition | | ------------ | ------------------------------------------------------ | -| `loading` | The connection is catching up. | | `processing` | A prompt is being prepared or a response is streaming. | | `idle` | Neither of the above applies. | -`loading` takes precedence over `processing`, matching the existing -placeholder-key behavior. A configured value is used only when it contains +A configured value is used only when it contains non-whitespace text; absent or blank values fall back to the corresponding localized WebShell placeholder. diff --git a/packages/web-shell/README.md b/packages/web-shell/README.md index 53f1468216a..85b5cb59ec9 100644 --- a/packages/web-shell/README.md +++ b/packages/web-shell/README.md @@ -216,7 +216,7 @@ const projection = projectChatRecordsToDaemonTranscript(records); | `workspaceId` | `string` | 已注册工作区 id,主要用于定位已有 session;不会注册或锁定工作区 | | `workspaceCwd` | `string` | 已注册工作区路径,语义同 `workspaceId`;不会注册或锁定工作区,且优先于 `workspaceId` | | `lockWorkspaceCwd` | `string` | 锁定到指定工作区路径;未注册时自动持久注册,并隐藏其他工作区及添加、移除和选择入口 | -| `restartSseOnPrompt` | `boolean` | 每次 prompt 被 daemon 接收后重建 SSE;默认关闭 | +| `restartSseOnPrompt` | `boolean` | 每次 prompt 被 daemon 接收后重建存活 SSE 流;流断开时提交 prompt 总会立即重建(与此开关无关);默认关闭 | ### WebShell diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 49cc5f5cc28..f6f8e9c2c9f 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -5021,8 +5021,10 @@ describe('App composer footer renderer', () => { rerender({ renderComposerFooter: ComposerFooter }); await flush(); + // Catch-up no longer disables the composer (only a pending approval or + // prompt preparation does). expect(composerFooterProps.at(-1)).toEqual({ - disabled: true, + disabled: false, isRunning: true, currentMode: 'plan', currentModel: 'qwen-next', @@ -8449,9 +8451,9 @@ describe('App session callbacks', () => { ).toContain('Visible session title'); }); - it('submits through a disconnected session when prompt SSE restart is enabled', async () => { + it('submits through a disconnected session', async () => { mockConnection.status = 'disconnected'; - renderApp({ restartSseOnPrompt: true }); + renderApp(); await act(async () => { testState.latestChatEditorProps?.onSubmit('recover connection'); @@ -9884,7 +9886,6 @@ describe('App session callbacks', () => { it('uses configured composer placeholders by state and falls back for blank values', async () => { const composerPlaceholders = { idle: 'Ask a question', - loading: 'Preparing chat', processing: 'Working on it', }; const { rerender } = renderApp({ composerPlaceholders }); @@ -9910,8 +9911,10 @@ describe('App session callbacks', () => { mockConnection.catchingUp = true; rerender({ composerPlaceholders }); await flush(); + // Catch-up no longer overrides the streaming placeholder: the composer + // keeps its processing text while history replays in the background. expect(testState.latestChatEditorProps?.placeholderText).toBe( - 'Preparing chat', + 'Working on it', ); mockConnection.catchingUp = false; diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 108ecabf767..964f177cbd6 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -8664,7 +8664,6 @@ export function App({ shouldBlockComposerSubmit({ connectionStatus: connectionRef.current.status, hasSession: Boolean(connectionRef.current.sessionId), - restartSseOnPrompt: Boolean(restartSseOnPrompt), }) ) { pushToast('warning', t('editor.connectionDisconnected')); @@ -9800,7 +9799,6 @@ export function App({ runVisibleBtw, reconcileCatalogRename, requireActiveSessionForLocalCommand, - restartSseOnPrompt, resumeChatBottomFollow, selectedLanguage, setPendingModel, @@ -10316,7 +10314,6 @@ export function App({ const isDisabled = sessionWriteBlocked || shouldDisableComposerInput({ - catchingUp: Boolean(connection.catchingUp), pendingApproval: pendingApproval !== null, isPreparingPrompt, }); @@ -10344,7 +10341,6 @@ export function App({ ? latestUserBlock : undefined; const composerPlaceholderInputState = { - catchingUp: Boolean(connection.catchingUp), isPreparingPrompt, isStreaming: streamingState !== 'idle', }; diff --git a/packages/web-shell/client/components/ChatPane.test.tsx b/packages/web-shell/client/components/ChatPane.test.tsx index c74660d8906..54d0d6ec211 100644 --- a/packages/web-shell/client/components/ChatPane.test.tsx +++ b/packages/web-shell/client/components/ChatPane.test.tsx @@ -1237,22 +1237,9 @@ describe('ChatPane', () => { expect(sendPrompt).not.toHaveBeenCalled(); }); - it('does not submit while the pane is disconnected', () => { + it('submits while disconnected when a session exists', () => { connectionState.status = 'disconnected'; render(); - let returned: boolean | undefined; - act(() => { - returned = latestOnSubmit!('hi'); - }); - expect(returned).toBe(false); - expect(sendPrompt).not.toHaveBeenCalled(); - expect(enqueuePrompt).not.toHaveBeenCalled(); - }); - - it('submits while disconnected when prompt SSE restart is enabled', () => { - connectionState.status = 'disconnected'; - render({ restartSseOnPrompt: true }); - act(() => { latestOnSubmit!('hi'); }); @@ -1263,10 +1250,10 @@ describe('ChatPane', () => { ); }); - it('does not submit without a recoverable disconnected session', () => { + it('does not submit without a session while disconnected', () => { connectionState.status = 'disconnected'; connectionState.sessionId = undefined; - render({ restartSseOnPrompt: true }); + render(); act(() => { latestOnSubmit!('hi'); diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx index dba968eac29..4a81bb14ca3 100644 --- a/packages/web-shell/client/components/ChatPane.tsx +++ b/packages/web-shell/client/components/ChatPane.tsx @@ -190,8 +190,6 @@ export interface ChatPaneProps { artifacts: readonly DaemonSessionArtifact[], ) => void; messageTurnOutputs?: readonly TurnOutputKind[]; - /** Allow prompt admission to recover a disconnected SSE stream. */ - restartSseOnPrompt?: boolean; /** Render inside a parent surface that already provides its own frame. */ embedded?: boolean; onFirstPromptAdmitted?: (text: string) => void; @@ -226,7 +224,6 @@ export function ChatPane({ onOpenMonitor, onPaneArtifactsChange, messageTurnOutputs, - restartSseOnPrompt = false, embedded = false, onFirstPromptAdmitted, reportCatalogTurnCompletion = true, @@ -584,7 +581,6 @@ export function ChatPane({ shouldBlockComposerSubmit({ connectionStatus: connection.status, hasSession: Boolean(connection.sessionId), - restartSseOnPrompt, }) ) { return false; @@ -679,7 +675,6 @@ export function ChatPane({ onFirstPromptAdmitted, onImageIngestionNotice, reportError, - restartSseOnPrompt, sessionCatalogController, t, ], diff --git a/packages/web-shell/client/components/SplitView.test.tsx b/packages/web-shell/client/components/SplitView.test.tsx index f76714e00e7..228bc9ff9b9 100644 --- a/packages/web-shell/client/components/SplitView.test.tsx +++ b/packages/web-shell/client/components/SplitView.test.tsx @@ -94,7 +94,6 @@ vi.mock('./ChatPane', () => ({ data-testid="chat-pane" data-pane-workspace={props.workspaceCwd} data-maximized={props.isMaximized ? 'true' : 'false'} - data-pane-restart-sse={props.restartSseOnPrompt ? 'true' : 'false'} data-slash-handler={props.onSlashCommand ? 'true' : 'false'} data-hidden={props.hidden ? 'true' : 'false'} data-report-catalog-turn-completion={ @@ -258,11 +257,6 @@ describe('SplitView', () => { .querySelector('[data-session="s1"]') ?.getAttribute('data-restart-sse'), ).toBe('true'); - expect( - container! - .querySelector('[data-session="s1"] [data-testid="chat-pane"]') - ?.getAttribute('data-pane-restart-sse'), - ).toBe('true'); }); it('passes the host slash command handler to every pane', () => { diff --git a/packages/web-shell/client/components/SplitView.tsx b/packages/web-shell/client/components/SplitView.tsx index 9f8171f44a5..8e9c327b86b 100644 --- a/packages/web-shell/client/components/SplitView.tsx +++ b/packages/web-shell/client/components/SplitView.tsx @@ -508,7 +508,6 @@ export function SplitView({ onOpenMonitor={onOpenMonitor} onPaneArtifactsChange={onPaneArtifactsChange} messageTurnOutputs={messageTurnOutputs} - restartSseOnPrompt={restartSseOnPrompt} sessionWorkflowEnabled={sessionWorkflowEnabled} /> diff --git a/packages/web-shell/client/index.tsx b/packages/web-shell/client/index.tsx index 4ffd92fe0d4..e628695af5d 100644 --- a/packages/web-shell/client/index.tsx +++ b/packages/web-shell/client/index.tsx @@ -26,7 +26,11 @@ export interface WebShellWithProvidersProps extends WebShellProps { lockWorkspaceCwd?: string; /** Client identity to reuse when attaching to an externally created session. */ clientId?: string; - /** Restart the SSE event stream after each accepted prompt. Disabled by default. */ + /** + * Restart a live SSE event stream after each accepted prompt. Disabled by + * default. A stream that is already down is always rebuilt immediately on + * prompt admission, regardless of this flag. + */ restartSseOnPrompt?: boolean; /** Persisted transcript records requested per page. Defaults to 100; valid range is 1–500. */ historyPageSize?: number; diff --git a/packages/web-shell/client/utils/composerInputState.test.ts b/packages/web-shell/client/utils/composerInputState.test.ts index ca9a648135c..54bac1d0359 100644 --- a/packages/web-shell/client/utils/composerInputState.test.ts +++ b/packages/web-shell/client/utils/composerInputState.test.ts @@ -7,56 +7,33 @@ import { } from './composerInputState'; describe('composer input state', () => { - it('keeps the composer editable while the SSE connection is disconnected', () => { + it('keeps the composer editable while idle', () => { + // Catch-up no longer participates in the composer input state: the + // function only takes approval/preparation flags, so this unit covers + // the idle case (catch-up behaviour is guarded by App integration tests). expect( shouldDisableComposerInput({ - catchingUp: false, pendingApproval: false, isPreparingPrompt: false, }), ).toBe(false); expect( getComposerPlaceholderKey({ - catchingUp: false, - isPreparingPrompt: false, - isStreaming: false, - }), - ).toBe('editor.placeholder'); - expect( - getComposerPlaceholderKey({ - catchingUp: false, isPreparingPrompt: false, isStreaming: false, }), ).toBe('editor.placeholder'); }); - it('keeps loading state only for catch-up or prompt preparation', () => { + it('keeps disabling the composer for prompt preparation', () => { expect( shouldDisableComposerInput({ - catchingUp: true, - pendingApproval: false, - isPreparingPrompt: false, - }), - ).toBe(true); - expect( - getComposerPlaceholderKey({ - catchingUp: true, - isPreparingPrompt: false, - isStreaming: false, - }), - ).toBe('common.loading'); - - expect( - shouldDisableComposerInput({ - catchingUp: false, pendingApproval: false, isPreparingPrompt: true, }), ).toBe(true); expect( getComposerPlaceholderKey({ - catchingUp: false, isPreparingPrompt: true, isStreaming: false, }), @@ -66,7 +43,6 @@ describe('composer input state', () => { it('shows processing placeholder while streaming', () => { expect( getComposerPlaceholderKey({ - catchingUp: false, isPreparingPrompt: false, isStreaming: true, }), @@ -76,22 +52,19 @@ describe('composer input state', () => { it('exposes the semantic placeholder state independently of i18n keys', () => { expect( getComposerPlaceholderState({ - catchingUp: false, isPreparingPrompt: false, isStreaming: false, }), ).toBe('idle'); expect( getComposerPlaceholderState({ - catchingUp: true, isPreparingPrompt: true, isStreaming: true, }), - ).toBe('loading'); + ).toBe('processing'); expect( getComposerPlaceholderState({ - catchingUp: false, - isPreparingPrompt: true, + isPreparingPrompt: false, isStreaming: true, }), ).toBe('processing'); @@ -100,58 +73,47 @@ describe('composer input state', () => { it('still disables editing for pending approvals', () => { expect( shouldDisableComposerInput({ - catchingUp: false, pendingApproval: true, isPreparingPrompt: false, }), ).toBe(true); }); - it('blocks submit only after the connection reaches a failed state', () => { + it('blocks submit only on error or a disconnected session without a session', () => { expect( shouldBlockComposerSubmit({ - connectionStatus: 'disconnected', + connectionStatus: 'error', hasSession: true, - restartSseOnPrompt: false, }), ).toBe(true); expect( shouldBlockComposerSubmit({ - connectionStatus: 'error', - hasSession: true, - restartSseOnPrompt: true, + connectionStatus: 'disconnected', + hasSession: false, }), ).toBe(true); expect( shouldBlockComposerSubmit({ connectionStatus: 'connecting', hasSession: false, - restartSseOnPrompt: false, }), ).toBe(false); expect( shouldBlockComposerSubmit({ connectionStatus: 'connected', hasSession: false, - restartSseOnPrompt: false, }), ).toBe(false); }); - it('allows a disconnected session to submit when prompt SSE restart is enabled', () => { + it('allows a disconnected session with an existing session to submit', () => { + // The prompt is submitted over HTTP and the SSE stream is rebuilt on + // admission, so a down stream does not block sending. expect( shouldBlockComposerSubmit({ connectionStatus: 'disconnected', hasSession: true, - restartSseOnPrompt: true, }), ).toBe(false); - expect( - shouldBlockComposerSubmit({ - connectionStatus: 'disconnected', - hasSession: false, - restartSseOnPrompt: true, - }), - ).toBe(true); }); }); diff --git a/packages/web-shell/client/utils/composerInputState.ts b/packages/web-shell/client/utils/composerInputState.ts index 481a84004cd..e3ea3446e80 100644 --- a/packages/web-shell/client/utils/composerInputState.ts +++ b/packages/web-shell/client/utils/composerInputState.ts @@ -5,42 +5,34 @@ type ComposerConnectionStatus = | 'disconnected' | 'error'; -export type ComposerPlaceholderState = 'idle' | 'loading' | 'processing'; +export type ComposerPlaceholderState = 'idle' | 'processing'; export function shouldDisableComposerInput({ - catchingUp, pendingApproval, isPreparingPrompt, }: { - catchingUp: boolean; pendingApproval: boolean; isPreparingPrompt: boolean; }): boolean { - return Boolean(catchingUp || pendingApproval || isPreparingPrompt); + return Boolean(pendingApproval || isPreparingPrompt); } export function getComposerPlaceholderState({ - catchingUp, isPreparingPrompt, isStreaming, }: { - catchingUp: boolean; isPreparingPrompt: boolean; isStreaming: boolean; }): ComposerPlaceholderState { - if (catchingUp) return 'loading'; if (isPreparingPrompt || isStreaming) return 'processing'; return 'idle'; } export function getComposerPlaceholderKey(input: { - catchingUp: boolean; isPreparingPrompt: boolean; isStreaming: boolean; -}): 'common.loading' | 'editor.processing' | 'editor.placeholder' { +}): 'editor.processing' | 'editor.placeholder' { switch (getComposerPlaceholderState(input)) { - case 'loading': - return 'common.loading'; case 'processing': return 'editor.processing'; case 'idle': @@ -51,14 +43,10 @@ export function getComposerPlaceholderKey(input: { export function shouldBlockComposerSubmit({ connectionStatus, hasSession, - restartSseOnPrompt, }: { connectionStatus: ComposerConnectionStatus; hasSession: boolean; - restartSseOnPrompt: boolean; }): boolean { if (connectionStatus === 'error') return true; - return ( - connectionStatus === 'disconnected' && (!restartSseOnPrompt || !hasSession) - ); + return connectionStatus === 'disconnected' && !hasSession; } diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index 6b93cabdefb..88aede7df52 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -2212,6 +2212,78 @@ describe('DaemonSessionProvider', () => { }); }); + it('rebuilds the SSE stream immediately when a prompt is submitted while the stream is down', async () => { + const turnComplete = createDeferred(); + const secondSubscriptionStarted = createDeferred(); + const eventSignals: AbortSignal[] = []; + const events = vi.fn(async function* downStreamEvents( + opts: { signal?: AbortSignal } = {}, + ) { + if (opts.signal) eventSignals.push(opts.signal); + const subscription = events.mock.calls.length; + // First subscription ends immediately: the stream is down and the + // provider enters reconnect backoff. + if (subscription === 1) { + yield* []; + return; + } + secondSubscriptionStarted.resolve(); + await Promise.race([ + turnComplete.promise, + new Promise((resolve) => + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }), + ), + ]); + }); + const session = createMockSession({ + submitPrompt: vi.fn(async () => ({ + promptId: 'prompt-1', + lastEventId: 10, + })), + events, + }); + sdkMocks.sessions.push(session); + let actions: DaemonUiSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + // Long backoff: the rebuild must be triggered by the prompt admission, + // not by the reconnect timer elapsing. + reconnectDelayMs: 60_000, + maxReconnectDelayMs: 60_000, + }); + const providerActions = requireActions(actions); + + // The first subscription has ended; the provider is now in backoff. + await vi.waitFor(() => expect(events).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(connection?.status).toBe('disconnected')); + + // Submitting a prompt rebuilds the stream immediately (no backoff wait). + await act(async () => { + void providerActions.sendPrompt('hello'); + await secondSubscriptionStarted.promise; + }); + + expect(events).toHaveBeenCalledTimes(2); + // The session handle is preserved: no full reload, direct SSE resume. + expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledTimes(1); + expect(eventSignals[1]?.aborted).toBe(false); + + turnComplete.resolve(); + await act(async () => { + await flushPromises(); + }); + }); + it('shows waiting state when a queued prompt starts before assistant output', async () => { const turnComplete = createDeferred(); const session = createMockSession({ diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index a3df97d06a9..5e16254fc1f 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -633,6 +633,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { >(new WeakSet()); const eventOptionsRef = useRef({ suppressOwnUserEcho, includeRawEvent }); const reconnectConfigRef = useRef({ reconnectDelayMs, maxReconnectDelayMs }); + // Aborts the reconnect backoff wait so a caller can force an immediate SSE + // rebuild (e.g. a prompt submitted while the stream is down). + const reconnectAbortRef = useRef(undefined); const loadWarningsRef = useRef(loadWarnings); const historyPageSizeRef = useRef(historyPageSize); const subagentTranscriptModeRef = useRef(subagentTranscriptMode); @@ -2696,7 +2699,13 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { status: 'disconnected', error: undefined, })); - await delay(delayMs, abort.signal); + reconnectAbortRef.current?.abort(); + const reconnectAbort = new AbortController(); + reconnectAbortRef.current = reconnectAbort; + const onEffectAbort = () => reconnectAbort.abort(); + abort.signal.addEventListener('abort', onEffectAbort, { once: true }); + await delay(delayMs, reconnectAbort.signal); + abort.signal.removeEventListener('abort', onEffectAbort); } }; @@ -2950,11 +2959,20 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { hasCurrentSessionActivePromptRef.current = () => false; }, restartEventStream: (sessionId: string) => { - if (!restartEventStreamOnPrompt) return; const eventStream = eventStreamRef.current; - if (eventStream?.sessionId !== sessionId) return; - eventStream.restartRequested = true; - eventStream.controller.abort(); + if (eventStream?.sessionId === sessionId) { + // Live stream: restart it only in the opt-in prompt-restart mode. + if (!restartEventStreamOnPrompt) return; + eventStream.restartRequested = true; + eventStream.controller.abort(); + return; + } + // The stream is already down (reconnecting with backoff): a prompt + // was submitted, so skip the remaining wait and rebuild the SSE + // immediately so the response events land without the backoff delay. + if (sessionRef.current?.sessionId === sessionId) { + reconnectAbortRef.current?.abort(); + } }, getCreateSessionRequest: () => ({ ...createSessionRequestRef.current, diff --git a/packages/webui/src/daemon/session/types.ts b/packages/webui/src/daemon/session/types.ts index 709c41aabb9..be4cbf693ba 100644 --- a/packages/webui/src/daemon/session/types.ts +++ b/packages/webui/src/daemon/session/types.ts @@ -148,7 +148,11 @@ export interface DaemonSessionProviderProps { autoConnect?: boolean; /** Reconnect automatically after recoverable daemon/session failures. */ autoReconnect?: boolean; - /** Restart the SSE event stream after each accepted prompt. */ + /** + * Restart a live SSE event stream after each accepted prompt. A stream that + * is already down is always rebuilt immediately on prompt admission, + * regardless of this flag. + */ restartEventStreamOnPrompt?: boolean; /** Initial reconnect delay in milliseconds. */ reconnectDelayMs?: number;