From f67e84bbf22fc019760e838983f945f09e89aa72 Mon Sep 17 00:00:00 2001 From: wsp Date: Sat, 27 Jun 2026 00:47:08 +0800 Subject: [PATCH] fix(web-ui): lock chat input mode controls for Claw sessions Centralize chat input mode policy so fixed collaboration sessions are handled consistently across the mode capsule, boost menu, slash commands, and keyboard mode cycling. - Treat Claw, Cowork, assistant workspace, and ACP sessions as fixed-mode contexts - Keep Claw sessions pinned even when assistant workspace resolution is temporarily stale - Filter fixed collaboration modes out of selectable chat input boosts - Add focused coverage for fixed-mode policy and Claw synchronization --- .../src/flow_chat/components/ChatInput.tsx | 23 ++-- .../src/flow_chat/utils/chatInputMode.test.ts | 124 ++++++++++++++++++ .../src/flow_chat/utils/chatInputMode.ts | 86 +++++++++++- 3 files changed, 224 insertions(+), 9 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 5926d958c..5fbc005db 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -65,7 +65,9 @@ import { DEFAULT_CHAT_INPUT_MODE_CONFIG_PATH, normalizeUserDefaultChatInputModeId, resolveAvailableChatInputMode, + resolveChatInputModePolicy, resolveSessionAssistantWorkspace, + resolveSwitchableChatInputModes, } from '../utils/chatInputMode'; import { useSceneStore } from '@/app/stores/sceneStore'; import type { SceneTabId } from '@/app/components/SceneBar/types'; @@ -657,16 +659,21 @@ export const ChatInput: React.FC = ({ const activeSessionMode = effectiveTargetSessionId ? acpTargetAgentType || flowChatState.sessions.get(effectiveTargetSessionId)?.mode : undefined; - const canSwitchModes = !isAssistantWorkspace && currentMode !== 'Cowork' && !isAcpTargetSession; + const chatInputModePolicy = useMemo( + () => resolveChatInputModePolicy({ + currentMode, + isAssistantWorkspace, + sessionMode: activeSessionMode, + isAcpTargetSession, + }), + [activeSessionMode, currentMode, isAcpTargetSession, isAssistantWorkspace], + ); + const canSwitchModes = chatInputModePolicy.canSwitchModes; - // Session-level mode policy: Cowork sessions are fixed; code sessions should not switch into Cowork. + // Session-level mode policy: fixed collaboration modes are not selectable boosts. const switchableModes = useMemo( - () => - modeState.available.filter(mode => - mode.id !== 'Cowork' && - (isAssistantWorkspace || mode.id !== 'Claw') - ), - [isAssistantWorkspace, modeState.available] + () => resolveSwitchableChatInputModes(modeState.available), + [modeState.available] ); // Stable refs for Shift+Tab mode cycling (avoids adding deps to handleKeyDown) diff --git a/src/web-ui/src/flow_chat/utils/chatInputMode.test.ts b/src/web-ui/src/flow_chat/utils/chatInputMode.test.ts index 5c50ad60b..6247757ba 100644 --- a/src/web-ui/src/flow_chat/utils/chatInputMode.test.ts +++ b/src/web-ui/src/flow_chat/utils/chatInputMode.test.ts @@ -3,7 +3,9 @@ import { describe, expect, it } from 'vitest'; import { normalizeUserDefaultChatInputModeId, resolveAvailableChatInputMode, + resolveChatInputModePolicy, resolveSessionAssistantWorkspace, + resolveSwitchableChatInputModes, resolveWorkspaceChatInputMode, } from './chatInputMode'; import { WorkspaceKind, type WorkspaceInfo, WorkspaceType } from '@/shared/types'; @@ -91,6 +93,117 @@ describe('resolveWorkspaceChatInputMode', () => { }) ).toBe('agentic'); }); + + it('keeps Claw sessions synchronized even before workspace state identifies the assistant workspace', () => { + expect( + resolveWorkspaceChatInputMode({ + currentMode: 'agentic', + isAssistantWorkspace: false, + sessionMode: 'Claw', + }) + ).toBe('Claw'); + }); +}); + +describe('resolveChatInputModePolicy', () => { + it('allows mode switching for normal code sessions', () => { + expect( + resolveChatInputModePolicy({ + currentMode: 'agentic', + isAssistantWorkspace: false, + sessionMode: 'agentic', + }), + ).toEqual({ + canSwitchModes: true, + fixedModeId: null, + fixedReason: null, + }); + }); + + it('fixes assistant workspaces to Claw', () => { + expect( + resolveChatInputModePolicy({ + currentMode: 'agentic', + isAssistantWorkspace: true, + sessionMode: 'agentic', + }), + ).toEqual({ + canSwitchModes: false, + fixedModeId: 'Claw', + fixedReason: 'assistant-workspace', + }); + }); + + it('fixes Claw sessions even when workspace resolution is temporarily stale', () => { + expect( + resolveChatInputModePolicy({ + currentMode: 'agentic', + isAssistantWorkspace: false, + sessionMode: 'claw', + }), + ).toEqual({ + canSwitchModes: false, + fixedModeId: 'Claw', + fixedReason: 'session-mode', + }); + }); + + it('fixes Cowork sessions from current or session mode', () => { + expect( + resolveChatInputModePolicy({ + currentMode: 'Cowork', + isAssistantWorkspace: false, + sessionMode: 'agentic', + }), + ).toMatchObject({ + canSwitchModes: false, + fixedModeId: 'Cowork', + fixedReason: 'current-mode', + }); + + expect( + resolveChatInputModePolicy({ + currentMode: 'agentic', + isAssistantWorkspace: false, + sessionMode: 'cowork', + }), + ).toMatchObject({ + canSwitchModes: false, + fixedModeId: 'Cowork', + fixedReason: 'session-mode', + }); + }); + + it('fixes ACP sessions without treating them as a product mode', () => { + expect( + resolveChatInputModePolicy({ + currentMode: 'agentic', + isAssistantWorkspace: false, + sessionMode: 'acp:example', + isAcpTargetSession: true, + }), + ).toEqual({ + canSwitchModes: false, + fixedModeId: null, + fixedReason: 'acp-session', + }); + }); +}); + +describe('resolveSwitchableChatInputModes', () => { + it('removes fixed collaboration modes from boost selection', () => { + expect( + resolveSwitchableChatInputModes([ + { id: 'agentic' }, + { id: 'Cowork' }, + { id: 'Claw' }, + { id: 'PlannerPlus' }, + ]), + ).toEqual([ + { id: 'agentic' }, + { id: 'PlannerPlus' }, + ]); + }); }); describe('resolveSessionAssistantWorkspace', () => { @@ -199,6 +312,17 @@ describe('resolveAvailableChatInputMode', () => { ).toBe('Claw'); }); + it('keeps Claw sessions pinned even before assistant workspace resolution catches up', () => { + expect( + resolveAvailableChatInputMode({ + currentMode: 'agentic', + isAssistantWorkspace: false, + sessionMode: 'Claw', + availableModeIds: ['agentic', 'Claw', 'PlannerPlus'], + }), + ).toBe('Claw'); + }); + it('falls back to the first available mode when agentic is unavailable', () => { expect( resolveAvailableChatInputMode({ diff --git a/src/web-ui/src/flow_chat/utils/chatInputMode.ts b/src/web-ui/src/flow_chat/utils/chatInputMode.ts index 9bb8d2ed6..e5cd476b0 100644 --- a/src/web-ui/src/flow_chat/utils/chatInputMode.ts +++ b/src/web-ui/src/flow_chat/utils/chatInputMode.ts @@ -2,11 +2,25 @@ import { WorkspaceKind, type WorkspaceInfo } from '@/shared/types'; export const DEFAULT_CHAT_INPUT_MODE_CONFIG_PATH = 'app.flow_chat.default_mode_id'; +const FIXED_CHAT_INPUT_MODE_IDS = new Set(['cowork', 'claw']); + type WorkspaceResolutionInfo = Pick< WorkspaceInfo, 'id' | 'rootPath' | 'workspaceKind' | 'connectionId' >; +export type ChatInputFixedModeReason = + | 'assistant-workspace' + | 'acp-session' + | 'current-mode' + | 'session-mode'; + +export interface ChatInputModePolicy { + canSwitchModes: boolean; + fixedModeId: string | null; + fixedReason: ChatInputFixedModeReason | null; +} + function normalizeOptionalString(value: string | null | undefined): string | null { if (typeof value !== 'string') { return null; @@ -139,6 +153,76 @@ export function resolveSessionAssistantWorkspace(params: { return params.currentWorkspace?.workspaceKind === WorkspaceKind.Assistant; } +function normalizeModeLookupId(value: string | null | undefined): string | null { + return normalizeOptionalString(value)?.toLowerCase() ?? null; +} + +function canonicalFixedModeId(value: string | null | undefined): string | null { + switch (normalizeModeLookupId(value)) { + case 'cowork': + return 'Cowork'; + case 'claw': + return 'Claw'; + default: + return null; + } +} + +export function resolveChatInputModePolicy(params: { + currentMode: string; + isAssistantWorkspace: boolean; + sessionMode?: string | null; + isAcpTargetSession?: boolean; +}): ChatInputModePolicy { + if (params.isAcpTargetSession) { + return { + canSwitchModes: false, + fixedModeId: null, + fixedReason: 'acp-session', + }; + } + + if (params.isAssistantWorkspace) { + return { + canSwitchModes: false, + fixedModeId: 'Claw', + fixedReason: 'assistant-workspace', + }; + } + + const fixedSessionModeId = canonicalFixedModeId(params.sessionMode); + if (fixedSessionModeId) { + return { + canSwitchModes: false, + fixedModeId: fixedSessionModeId, + fixedReason: 'session-mode', + }; + } + + const fixedCurrentModeId = canonicalFixedModeId(params.currentMode); + if (fixedCurrentModeId) { + return { + canSwitchModes: false, + fixedModeId: fixedCurrentModeId, + fixedReason: 'current-mode', + }; + } + + return { + canSwitchModes: true, + fixedModeId: null, + fixedReason: null, + }; +} + +export function resolveSwitchableChatInputModes( + availableModes: Iterable, +): TMode[] { + return Array.from(availableModes).filter( + mode => !FIXED_CHAT_INPUT_MODE_IDS.has(normalizeModeLookupId(mode.id) ?? ''), + ); +} + export function resolveWorkspaceChatInputMode(params: { currentMode: string; isAssistantWorkspace: boolean; @@ -151,7 +235,7 @@ export function resolveWorkspaceChatInputMode(params: { } if (normalizedSessionMode?.toLowerCase() === 'claw') { - return null; + return params.currentMode === 'Claw' ? null : 'Claw'; } if (normalizedSessionMode && normalizedSessionMode !== params.currentMode) {