Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions docs/design/web-shell/webshell-composer-placeholders.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<WebShellComposerPlaceholderState, string>
Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion packages/web-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 8 additions & 5 deletions packages/web-shell/client/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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 });
Expand All @@ -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;
Expand Down
4 changes: 0 additions & 4 deletions packages/web-shell/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down Expand Up @@ -9800,7 +9799,6 @@ export function App({
runVisibleBtw,
reconcileCatalogRename,
requireActiveSessionForLocalCommand,
restartSseOnPrompt,
resumeChatBottomFollow,
selectedLanguage,
setPendingModel,
Expand Down Expand Up @@ -10316,7 +10314,6 @@ export function App({
const isDisabled =
sessionWriteBlocked ||
shouldDisableComposerInput({
catchingUp: Boolean(connection.catchingUp),
pendingApproval: pendingApproval !== null,
isPreparingPrompt,
});
Expand Down Expand Up @@ -10344,7 +10341,6 @@ export function App({
? latestUserBlock
: undefined;
const composerPlaceholderInputState = {
catchingUp: Boolean(connection.catchingUp),
isPreparingPrompt,
isStreaming: streamingState !== 'idle',
};
Expand Down
19 changes: 3 additions & 16 deletions packages/web-shell/client/components/ChatPane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand All @@ -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');
Expand Down
5 changes: 0 additions & 5 deletions packages/web-shell/client/components/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -226,7 +224,6 @@ export function ChatPane({
onOpenMonitor,
onPaneArtifactsChange,
messageTurnOutputs,
restartSseOnPrompt = false,
embedded = false,
onFirstPromptAdmitted,
reportCatalogTurnCompletion = true,
Expand Down Expand Up @@ -584,7 +581,6 @@ export function ChatPane({
shouldBlockComposerSubmit({
connectionStatus: connection.status,
hasSession: Boolean(connection.sessionId),
restartSseOnPrompt,
})
) {
return false;
Expand Down Expand Up @@ -679,7 +675,6 @@ export function ChatPane({
onFirstPromptAdmitted,
onImageIngestionNotice,
reportError,
restartSseOnPrompt,
sessionCatalogController,
t,
],
Expand Down
6 changes: 0 additions & 6 deletions packages/web-shell/client/components/SplitView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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={
Expand Down Expand Up @@ -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', () => {
Expand Down
1 change: 0 additions & 1 deletion packages/web-shell/client/components/SplitView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,6 @@ export function SplitView({
onOpenMonitor={onOpenMonitor}
onPaneArtifactsChange={onPaneArtifactsChange}
messageTurnOutputs={messageTurnOutputs}
restartSseOnPrompt={restartSseOnPrompt}
sessionWorkflowEnabled={sessionWorkflowEnabled}
/>
</DaemonSessionProvider>
Expand Down
6 changes: 5 additions & 1 deletion packages/web-shell/client/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
66 changes: 14 additions & 52 deletions packages/web-shell/client/utils/composerInputState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
Expand All @@ -66,7 +43,6 @@ describe('composer input state', () => {
it('shows processing placeholder while streaming', () => {
expect(
getComposerPlaceholderKey({
catchingUp: false,
isPreparingPrompt: false,
isStreaming: true,
}),
Expand All @@ -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');
Expand All @@ -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);
});
});
Loading
Loading