feat(web-shell): restore the full composer in split-view panes (#6510)

* feat(web-shell): restore the full composer in split-view panes

The in-window split view rendered each pane through a deliberately minimal composer: typing "/" opened nothing, and the approval-mode, model, and voice controls were all hidden. Wire each pane's composer to its own session so all four work per pane.

The slash menu lists the pane session's own daemon commands and is submitted through that session, where the daemon executes it server-side — so e.g. /clear clears that pane's session, not the outer chat. The approval-mode and model pickers drive the pane session's own setApprovalMode / setModel actions, and the SDK reflects the change back on the connection. The shared model-visibility filter moves to utils/composerModels so the main chat and split panes hide the same models.

* feat(web-shell): auto-approve pending tool call when a pane switches to yolo

Address review feedback on #6510: switching a split pane to yolo (or auto-edit for an edit tool) now auto-approves a tool call already awaiting approval in that pane — mirroring App's handleSetMode so the shortcut behaves the same as in the single-session chat. The pending approval is read through a ref so the async setApprovalMode resolution targets the approval current at that time, not a stale one.

Also cover the two untested branches of handleSelectMode: the isDaemonApprovalMode guard (invalid mode is rejected without hitting the daemon, reported via onError) and the setApprovalMode async-rejection path.
This commit is contained in:
Shaojin Wen 2026-07-08 15:01:36 +08:00 committed by GitHub
parent 1420566620
commit d8dc8043d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 274 additions and 12 deletions

View file

@ -105,6 +105,7 @@ import {
} from './utils/copyCommand';
import { isEditableTarget } from './utils/dom';
import { getModelDisplayName } from './utils/modelDisplay';
import { isVisibleComposerModel } from './utils/composerModels';
import { filterModelSwitchMessages } from './utils/modelSwitchMessages';
import { decideEscapeIntent } from './utils/escapeIntent';
import type { SkillInfo } from './completions/slashCompletion';
@ -240,7 +241,6 @@ const MAX_TOASTS = 4;
const BOUND_RUN_SWITCH_TIMEOUT_MS = 30_000;
const COMPACT_MODE_SETTING_KEY = 'ui.compactMode';
const HIDE_TIPS_SETTING_KEY = 'ui.hideTips';
const HIDDEN_COMPOSER_MODEL_IDS = new Set(['coder-model(qwen-oauth)']);
/** Maps each ModelDialogMode to its i18n title key — single source of truth. */
const MODE_TITLE_KEY: Record<ModelDialogMode, string> = {
@ -250,10 +250,6 @@ const MODE_TITLE_KEY: Record<ModelDialogMode, string> = {
vision: 'model.setVision',
};
function isVisibleComposerModel(model: { id: string }): boolean {
return !HIDDEN_COMPOSER_MODEL_IDS.has(model.id);
}
function normalizeHiddenCommand(command: string): string {
return command.trim().replace(/^\/+/, '').toLowerCase();
}

View file

@ -25,9 +25,18 @@ let sendPromptAdmit: (() => void) | undefined;
const sendPrompt = vi.fn(async () => ({}) as any);
const submitPermission = vi.fn(async () => {});
const cancel = vi.fn(async () => {});
const setApprovalMode = vi.fn(async (mode: string) => ({ mode }));
const setModel = vi.fn(async () => ({}) as any);
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
useActions: () => ({ sendPrompt, submitPermission, cancel }),
DAEMON_APPROVAL_MODES: ['default', 'plan', 'auto-edit', 'auto', 'yolo'],
useActions: () => ({
sendPrompt,
submitPermission,
cancel,
setApprovalMode,
setModel,
}),
useConnection: () => connectionState,
useStreamingState: () => streamingStateValue,
useTranscriptBlocks: () => [],
@ -77,8 +86,37 @@ vi.mock('./ChatEditor', () => ({
<button data-testid="pane-cancel" onClick={props.onCancel}>
cancel
</button>
<button
data-testid="pane-pick-mode"
onClick={() => props.onSelectMode?.('yolo')}
>
mode
</button>
<button
data-testid="pane-pick-badmode"
onClick={() => props.onSelectMode?.('totally-bogus')}
>
badmode
</button>
<button
data-testid="pane-pick-model"
onClick={() => props.onSelectModel?.('gpt-x')}
>
model
</button>
<span data-testid="pane-running">{String(props.isRunning)}</span>
<span data-testid="pane-dialogopen">{String(props.dialogOpen)}</span>
<span data-testid="pane-toolbar">
{JSON.stringify(props.visibleToolbarActions ?? null)}
</span>
<span data-testid="pane-commands">
{String((props.commands ?? []).length)}
</span>
<span data-testid="pane-mode">{String(props.currentMode)}</span>
<span data-testid="pane-model">{String(props.currentModel)}</span>
<span data-testid="pane-models">
{JSON.stringify(props.availableModels ?? null)}
</span>
</div>
);
},
@ -137,6 +175,8 @@ beforeEach(() => {
);
submitPermission.mockClear();
cancel.mockClear();
setApprovalMode.mockClear();
setModel.mockClear();
});
afterEach(() => {
@ -370,4 +410,124 @@ describe('ChatPane', () => {
'none',
);
});
it('enables the interactive composer controls (approval mode, model, voice)', () => {
render();
expect(testid('pane-toolbar')?.textContent).toBe(
JSON.stringify(['approvalMode', 'model', 'voice']),
);
});
it("lists the pane session's own commands in the slash menu", () => {
connectionState.commands = [
{ name: 'clear', description: 'Clear', source: 'builtin-command' },
{ name: 'compress', description: 'Compress', source: 'builtin-command' },
];
render();
expect(testid('pane-commands')?.textContent).toBe('2');
});
it('hides internal composer models and labels the rest', () => {
connectionState.models = [
{ id: 'coder-model(qwen-oauth)', label: 'Coder' },
{ id: 'qwen-max', label: 'qwen-max' },
];
render();
const models = JSON.parse(testid('pane-models')!.textContent!);
expect(models.map((m: { id: string }) => m.id)).toEqual(['qwen-max']);
});
it("drives THIS pane's approval mode when one is picked", () => {
render();
act(() =>
testid('pane-pick-mode')!.dispatchEvent(
new MouseEvent('click', { bubbles: true }),
),
);
expect(setApprovalMode).toHaveBeenCalledWith('yolo');
});
it("switches THIS pane's model when one is picked", () => {
render();
act(() =>
testid('pane-pick-model')!.dispatchEvent(
new MouseEvent('click', { bubbles: true }),
),
);
expect(setModel).toHaveBeenCalledWith('gpt-x');
});
it("reflects the pane session's current mode and model", () => {
connectionState.currentMode = 'auto-edit';
connectionState.currentModel = 'qwen-max';
render();
expect(testid('pane-mode')?.textContent).toBe('auto-edit');
expect(testid('pane-model')?.textContent).toBe('qwen-max');
});
it('falls back to the default mode and empty model when unset', () => {
render();
expect(testid('pane-mode')?.textContent).toBe('default');
expect(testid('pane-model')?.textContent).toBe('');
});
it('reports a failed model switch to onError', async () => {
const onError = vi.fn();
setModel.mockRejectedValueOnce(new Error('switch failed'));
render({ onError });
await act(async () => {
testid('pane-pick-model')!.dispatchEvent(
new MouseEvent('click', { bubbles: true }),
);
await Promise.resolve();
await Promise.resolve();
});
expect(onError).toHaveBeenCalled();
});
it('reports a failed approval mode switch to onError', async () => {
const onError = vi.fn();
setApprovalMode.mockRejectedValueOnce(new Error('mode switch failed'));
render({ onError });
await act(async () => {
testid('pane-pick-mode')!.dispatchEvent(
new MouseEvent('click', { bubbles: true }),
);
await Promise.resolve();
await Promise.resolve();
});
expect(onError).toHaveBeenCalled();
});
it('rejects an invalid approval mode without calling the daemon', () => {
const onError = vi.fn();
render({ onError });
act(() =>
testid('pane-pick-badmode')!.dispatchEvent(
new MouseEvent('click', { bubbles: true }),
),
);
expect(setApprovalMode).not.toHaveBeenCalled();
expect(onError).toHaveBeenCalled();
});
it('auto-approves a pending tool call when the pane switches to yolo', async () => {
pendingPermission = {
id: 'perm-yolo',
toolName: 'write_file',
toolKind: 'edit',
options: [{ id: 'allow-1', label: 'Allow once', kind: 'allow_once' }],
rawInput: {},
};
render();
await act(async () => {
testid('pane-pick-mode')!.dispatchEvent(
new MouseEvent('click', { bubbles: true }),
);
await Promise.resolve();
await Promise.resolve();
});
expect(setApprovalMode).toHaveBeenCalledWith('yolo');
expect(submitPermission).toHaveBeenCalledWith('perm-yolo', 'allow-1');
});
});

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useRef } from 'react';
import {
useActions,
useConnection,
@ -17,15 +17,26 @@ import { extractPendingPermission } from '../adapters/transcriptAdapter';
import type { PromptImage } from '../adapters/promptTypes';
import type { ComposerSubmitCommit } from '../hooks/useComposerCore';
import { isAskUserPermission } from '../utils/askUserPermission';
import { isDaemonApprovalMode } from '../utils/sessionPreparation';
import { isVisibleComposerModel } from '../utils/composerModels';
import { getModelDisplayName } from '../utils/modelDisplay';
import { localizeBuiltinDescriptions } from '../constants/localCommands';
import { MessageList } from './MessageList';
import { StreamingStatus } from './StreamingStatus';
import { ChatEditor } from './ChatEditor';
import { ChatEditor, type ComposerToolbarAction } from './ChatEditor';
import { ToolApproval } from './messages/ToolApproval';
import { AskUserQuestion } from './messages/AskUserQuestion';
import styles from './ChatPane.module.css';
const EMPTY_COMMANDS: never[] = [];
const EMPTY_TOOLBAR: never[] = [];
// Split-view panes get the same interactive composer controls as the main chat,
// each scoped to the pane's own session: the approval-mode and model pickers,
// plus voice dictation. The width toggle is omitted (panes size themselves); the
// slash menu is populated from the session's own command list (see below).
const PANE_TOOLBAR_ACTIONS: readonly ComposerToolbarAction[] = [
'approvalMode',
'model',
'voice',
];
export interface ChatPaneProps {
/** Header label; falls back to the session's own display name / id. */
@ -66,6 +77,11 @@ export function ChatPane({ title, onClose, onError }: ChatPaneProps) {
pendingApproval && !isAskUser ? pendingApproval : null;
const pendingAskUserApproval =
pendingApproval && isAskUser ? pendingApproval : null;
// Tracked in a ref so an async approval-mode switch (handleSelectMode) reads
// the approval current when setApprovalMode *resolves*, not a stale one
// captured at click time — mirrors App's pendingApprovalRef.
const pendingToolApprovalRef = useRef(pendingToolApproval);
pendingToolApprovalRef.current = pendingToolApproval;
const approvalActive =
pendingToolApproval !== null || pendingAskUserApproval !== null;
const isResponding = streamingState !== 'idle';
@ -128,6 +144,74 @@ export function ChatPane({ title, onClose, onError }: ChatPaneProps) {
);
}, [actions, reportError]);
// Composer wiring, all scoped to THIS pane's own DaemonSession context. The
// slash menu lists the session's daemon commands — they run server-side when
// submitted (via sendPrompt), so e.g. `/clear` clears this pane's session, not
// the outer one. The approval-mode and model pickers likewise drive this
// session's own actions; the SDK reflects the change back on `connection`.
const commands = useMemo(
() => localizeBuiltinDescriptions(connection.commands ?? [], t),
[connection.commands, t],
);
const availableModels = useMemo(
() =>
(connection.models ?? []).filter(isVisibleComposerModel).map((model) => ({
id: model.id,
label: getModelDisplayName(model.label || model.id),
})),
[connection.models],
);
const handleSelectMode = useCallback(
(modeId: string) => {
// Modes always arrive from the toolbar's own picker, but narrow anyway so
// the daemon action gets a well-typed value (mirrors App's handleSetMode).
if (!isDaemonApprovalMode(modeId)) {
reportError(
new Error(`Unsupported approval mode: ${modeId}`),
'Failed to set approval mode',
);
return;
}
actions
.setApprovalMode(modeId)
.then(() => {
// Mirror App's handleSetMode: switching THIS pane to yolo (or
// auto-edit for an edit tool) auto-approves a tool call already
// awaiting approval in this pane, so the shortcut behaves the same as
// in the single-session chat.
const approval = pendingToolApprovalRef.current;
if (!approval) return;
const autoApprove =
modeId === 'yolo' ||
(modeId === 'auto-edit' && approval.toolKind === 'edit');
if (!autoApprove) return;
const allowOnce = approval.options.find(
(option) => option.kind === 'allow_once',
);
if (!allowOnce) return;
actions
.submitPermission(approval.id, allowOnce.id)
.catch((error: unknown) =>
reportError(error, 'Failed to auto-approve tool call'),
);
})
.catch((error: unknown) =>
reportError(error, 'Failed to set approval mode'),
);
},
[actions, reportError],
);
const handleSelectModel = useCallback(
(modelId: string) => {
actions
.setModel(modelId)
.catch((error: unknown) =>
reportError(error, 'Failed to switch model'),
);
},
[actions, reportError],
);
const headerLabel =
title || connection.displayName || connection.sessionId?.slice(0, 8) || '';
@ -212,8 +296,13 @@ export function ChatPane({ title, onClose, onError }: ChatPaneProps) {
onSubmit={handleSubmit}
onCancel={handleCancel}
isRunning={isResponding}
commands={EMPTY_COMMANDS}
visibleToolbarActions={EMPTY_TOOLBAR}
commands={commands}
visibleToolbarActions={PANE_TOOLBAR_ACTIONS}
currentMode={connection.currentMode ?? 'default'}
currentModel={connection.currentModel ?? ''}
availableModels={availableModels}
onSelectMode={handleSelectMode}
onSelectModel={handleSelectModel}
dialogOpen={approvalActive}
placeholderText={t('splitView.composerPlaceholder')}
/>

View file

@ -0,0 +1,17 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Model IDs hidden from the composer's model picker internal / duplicate
* entries that must not be user-selectable. Shared by the main chat composer
* (App) and the split-view pane composers (ChatPane) so both hide the same set.
*/
export const HIDDEN_COMPOSER_MODEL_IDS = new Set(['coder-model(qwen-oauth)']);
/** Whether a model may appear in the composer's model picker. */
export function isVisibleComposerModel(model: { id: string }): boolean {
return !HIDDEN_COMPOSER_MODEL_IDS.has(model.id);
}