diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 783e6e70cf..b00754ff55 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -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 = { @@ -250,10 +250,6 @@ const MODE_TITLE_KEY: Record = { 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(); } diff --git a/packages/web-shell/client/components/ChatPane.test.tsx b/packages/web-shell/client/components/ChatPane.test.tsx index 970cace7df..6354ac40bc 100644 --- a/packages/web-shell/client/components/ChatPane.test.tsx +++ b/packages/web-shell/client/components/ChatPane.test.tsx @@ -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', () => ({ + + + {String(props.isRunning)} {String(props.dialogOpen)} + + {JSON.stringify(props.visibleToolbarActions ?? null)} + + + {String((props.commands ?? []).length)} + + {String(props.currentMode)} + {String(props.currentModel)} + + {JSON.stringify(props.availableModels ?? null)} + ); }, @@ -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'); + }); }); diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx index a640e694ce..bc2b8899e7 100644 --- a/packages/web-shell/client/components/ChatPane.tsx +++ b/packages/web-shell/client/components/ChatPane.tsx @@ -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')} /> diff --git a/packages/web-shell/client/utils/composerModels.ts b/packages/web-shell/client/utils/composerModels.ts new file mode 100644 index 0000000000..8e18cf98c9 --- /dev/null +++ b/packages/web-shell/client/utils/composerModels.ts @@ -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); +}